재우니의 블로그

 

.NET 6.0 - Web Api 를 통해 Refresh Tokens 와 함께 JWT 인증 Tutorial

 

 

https://jasonwatmore.com/post/2022/01/24/net-6-jwt-authentication-with-refresh-tokens-tutorial-with-example-api#running-angular

 

.NET 6.0 - JWT Authentication with Refresh Tokens Tutorial with Example API | Jason Watmore's Blog

Tutorial built with .NET 6.0 Other versions available: In this tutorial we'll go through an example of how to implement JWT (JSON Web Token) authentication with refresh tokens in a .NET 6.0 API. For an extended example that includes email sign up, verific

jasonwatmore.com

 

설치된 nuget 정보를 보면, 비밀번호 암호화를 위한 BCrypt.Net-Next 를 사용하였습니다.

OpenID connect bearer token 을 얻고자 Microsoft.AspNetCore.Authentication.JwtBearer 을 사용한 부분이 존재하고요. 

System.IdentityModel.Tokens.Jwt 은 JSON Web Token 을 생성하거나 validation 또는 직렬화를 하는데 도움을 주는 라이브러리 입니다.

 

 

정보를 담는 DATABASE 는 Microsoft.EntityFrameworkCore.InMemory 를 활용하여 테스트 목적을 위해 ENTITY FRAMEWORK CORE 에서 제공한 IN-MEMORY 데이터베이스 제공자 입니다. 즉, 실제 db 서버를 설치하지 않고도 Entity Framework Core가 메모리 내 데이터베이스를 만들고 연결할 수 있도록 하는 EF Core InMemory 데이터베이스 공급자를 사용하도록 구성되었습니다. 

 

 

 

 

 

WEB API 컨트롤러를 보면, authenticate 와 refresh-token 를 호출하는 경로는 AllowAnonymous 로써, 비인증자도 접근이 가능합니다. 나머지는 전부 JWT TOKEN 값을 전부 체크하여 인증 된 경우에만 접근이 가능하도록 Authorize 되어 있습니다.

 

그리고 XSS(교차 사이트 스크립팅) 공격 과 CSRF(교차 사이트 요청 위조) 공격에 사용되지 않도록 하기 위해 보안에 신경을 쓴 흔적이 있는 코드 입니다.  ^^

 

namespace WebApi.Controllers;

using Microsoft.AspNetCore.Mvc;
using WebApi.Authorization;
using WebApi.Models.Users;
using WebApi.Services;

[Authorize]
[ApiController]
[Route("[controller]")]
public class UsersController : ControllerBase
{
    private IUserService _userService;

    public UsersController(IUserService userService)
    {
        _userService = userService;
    }

    [AllowAnonymous]
    [HttpPost("authenticate")]
    public IActionResult Authenticate(AuthenticateRequest model)
    {
        var response = _userService.Authenticate(model, ipAddress());
        setTokenCookie(response.RefreshToken);
        return Ok(response);
    }

    [AllowAnonymous]
    [HttpPost("refresh-token")]
    public IActionResult RefreshToken()
    {
        var refreshToken = Request.Cookies["refreshToken"];
        var response = _userService.RefreshToken(refreshToken, ipAddress());
        setTokenCookie(response.RefreshToken);
        return Ok(response);
    }

    [HttpPost("revoke-token")]
    public IActionResult RevokeToken(RevokeTokenRequest model)
    {
        // accept refresh token in request body or cookie
        var token = model.Token ?? Request.Cookies["refreshToken"];

        if (string.IsNullOrEmpty(token))
            return BadRequest(new { message = "Token is required" });

        _userService.RevokeToken(token, ipAddress());
        return Ok(new { message = "Token revoked" });
    }

    [HttpGet]
    public IActionResult GetAll()
    {
        var users = _userService.GetAll();
        return Ok(users);
    }

    [HttpGet("{id}")]
    public IActionResult GetById(int id)
    {
        var user = _userService.GetById(id);
        return Ok(user);
    }

    [HttpGet("{id}/refresh-tokens")]
    public IActionResult GetRefreshTokens(int id)
    {
        var user = _userService.GetById(id);
        return Ok(user.RefreshTokens);
    }

    // helper methods

    private void setTokenCookie(string token)
    {
        // append cookie with refresh token to the http response
        var cookieOptions = new CookieOptions
        {
            HttpOnly = true,
            Expires = DateTime.UtcNow.AddDays(7)
        };
        Response.Cookies.Append("refreshToken", token, cookieOptions);
    }

    private string ipAddress()
    {
        // get source ip address for the current request
        if (Request.Headers.ContainsKey("X-Forwarded-For"))
            return Request.Headers["X-Forwarded-For"];
        else
            return HttpContext.Connection.RemoteIpAddress.MapToIPv4().ToString();
    }
}

 

 

Refresh Token 값은 http only 로 cookie 가 되어 있어서 아래 처럼 Request 객체를 통해 데이터를 얻을 수 있습니다.

 

var refreshToken = Request.Cookies["refreshToken"];

 

비밀번호는 BCrypt.Net.BCrypt.HashPassword() 함수를 통해 암호화하여 보관합니다.

    var context = scope.ServiceProvider.GetRequiredService<DataContext>();    
    var testUser = new User
    {
        FirstName = "Test",
        LastName = "User",
        Username = "test",
        PasswordHash = BCrypt.Net.BCrypt.HashPassword("test")
    };

 

비밀번호 비교는 아이디를 기반으로 조회하여 BCrypt.Verify(입력한 비빌번호값, 암호화된 암호화값) 함수를 통해 비교해서 true, false 를 알아 낼수 있습니다.

var user = _context.Users.SingleOrDefault(x => x.Username == model.Username);

        // validate
        if (user == null || !BCrypt.Verify(model.Password, user.PasswordHash))
            throw new AppException("Username or password is incorrect");

 

토큰 생성은 Microsoft.IdentityModel.Tokens 에서 제공한 SecurityTokenDescriptor 객체에 담아 token 을 CreateToken() 함수를 통해 생성합니다. 만기 기간은 15분, SigningCredentials 은 Sha256 암호화 기반으로 sign 키값을 넣어서 생성합니다.

 

    public string GenerateJwtToken(User user)
    {
        // generate token that is valid for 15 minutes
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(new[] { new Claim("id", user.Id.ToString()) }),
            Expires = DateTime.UtcNow.AddMinutes(15),
            SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
        };
        var token = tokenHandler.CreateToken(tokenDescriptor);
        return tokenHandler.WriteToken(token);
    }

 

 

토큰값을 통해 복원하여 cliam 에 담겨져 있는 id 정보를 추출하여 반환해 주는 부분입니다.

 

    public int? ValidateJwtToken(string token)
    {
        if (token == null)
            return null;

        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
        try
        {
            tokenHandler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = false,
                ValidateAudience = false,
                // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later)
                ClockSkew = TimeSpan.Zero
            }, out SecurityToken validatedToken);

            var jwtToken = (JwtSecurityToken)validatedToken;
            var userId = int.Parse(jwtToken.Claims.First(x => x.Type == "id").Value);

            // return user id from JWT token if validation successful
            return userId;
        }
        catch
        {
            // return null if validation fails
            return null;
        }
    }

 

Refresh token 생성은 base64 로 랜덤 값을 생성하여 기존에 발행한 토큰값과 동일한지 체크하고 유일한 token 이면 이를 반환합니다. 동일하면 재귀함수 처럼 다시 실행하여 유일값이 나올때 까지  실행합니다. Refresh token 의 만기 기간은 7일 입니다. 

    public RefreshToken GenerateRefreshToken(string ipAddress)
    {
        var refreshToken = new RefreshToken
        {
            Token = getUniqueToken(),
            // token is valid for 7 days
            Expires = DateTime.UtcNow.AddDays(7),
            Created = DateTime.UtcNow,
            CreatedByIp = ipAddress
        };

        return refreshToken;

        string getUniqueToken()
        {
            // token is a cryptographically strong random sequence of values
            var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
            // ensure token is unique by checking against db
            var tokenIsUnique = !_context.Users.Any(u => u.RefreshTokens.Any(t => t.Token == token));

            if (!tokenIsUnique)
                return getUniqueToken();
            
            return token;
        }
    }