Rest API Template 만들기 - EP 08 (DB 를 통해 AccessToken 및 RefreshToken 관리)

2023. 1. 12. 00:00ASPNET/ASPNET 7

반응형

이제 인증관련 하여 수정을 해야할 때이다.

기존에는 JWT 를 통해 AccessToken 을 사용하여 validation 처리를 aspnet core 내부적으로 하게 하였다. 

이렇게 만든 JWT 는 일반적인 validation 처리를 하기 때문에 logout 같은 처리를 하지 못하여

logout 이후에도 언제든지 token 을 그대로 사용 가능하다. 

즉 JWT 에 시간경과에 의한 expire 가 아닌 메뉴얼적인 expire 를 명령 할 수 없다. 

그래서 이러한 부분을 처리 하기 위해 자체적으로 validation 을 체크 하도록 tokenhandler 를 만들겠다.

db 상에 accessToken 및 RefreshToken 을 저장하고 Login user 정보와 연결할 것이다. 

해당 정보를 기반으로 Logout 시에는 Token 을 expire 하도록 하겠다. 

 

이정도로 만들게 되면 실제 field 에서 사용하는 인증처리에 기본은 할 수 있다고 본다. 

 

Login 시에 handler 에서 db 상에 accesstoken 과 refreshtoken 을 저장하자

 

먼저 Login 을 하기 전에 Register 를 추가하여 id 및 password 를 등록하자

password 는 암호화해서 처리하자. Helper 에 EncryptionHashHelper 를 추가하자

Helper/EncryptionHashHelper.cs

using System.Security.Cryptography;
using System.Text;

namespace ApiServer.Helper;

public class EncryptionHashHelper
{
    public static string EncryptPassword(string text, bool isUppercase = false)
    {
        string result = string.Empty;
        using (var algo = System.Security.Cryptography.SHA256.Create())
        {
            result = GenerateHashString(algo, text, isUppercase);
        }

        return result;
    }

    private static string GenerateHashString(HashAlgorithm algo, dynamic input, bool isUppercase)
    {
        if (input is string)
            input = Encoding.UTF8.GetBytes(input);

        if (!(input is Stream) && !(input is byte[]))
            throw new Exception($"can not GenerateHashString type  = {input}");

        // Compute hash from text parameter
        algo.ComputeHash(input);

        // Get has value in array of bytes
        var result = algo.Hash;

        if (result is null) throw new Exception($"can not ComputeHash");

        // Return as hexadecimal string
        return string.Join(
            string.Empty,
            result.Select(x => x.ToString(isUppercase ? "X2" : "x2")));
    }
}

 

Features/User/Register.cs

using ApiServer.Helper;
using Infrastructure;
using MediatR;

namespace ApiServer.Features.User;

public class Register
{
    public class Command : IRequest<Response>
    {
        public string Id { get; set; } = string.Empty;
        public string Name { get; set; } = string.Empty;
        public string Password { get; set; } = string.Empty;
    }
    public class Response
    {
        public bool Result { get; set; }
        public Error? Error { get; set; }
    }

    public class Error
    {
        public string Code { get; set; } = string.Empty;
        public string Message { get; set; } = string.Empty;
    }

    public class CommandHandler : IRequestHandler<Command, Response>
    {
        private readonly ApiServerContext _context;

        public CommandHandler(ApiServerContext context)
        {
            _context = context;
        }
        public async Task<Response> Handle(Command request, CancellationToken cancellationToken)
        {
            var response = new Response { Result = false };

            _context.Users.Add(new()
            {
                Id = request.Id,
                Name = request.Name,
                Password = EncryptionHashHelper.EncryptPassword(request.Password),
                UseFlag = true
            });

            await _context.SaveChangesAsync();

            response.Result = true;

            return response;
        }


    }
}

 

Features/User/UserController.cs

...
    [AllowAnonymous]
    [ProducesResponseType(typeof(Register.Response), StatusCodes.Status200OK)]
    [HttpPost("register")]
    public async Task<IActionResult> Register(Register.Command request)
    {
        if (!ModelState.IsValid)
            return BadRequest();

        var response = await _mediator.Send(request);
        return Ok(response);
    }
...

 

이제 Login 을 수정하자. 저장된 db 에서 id 및 password 를 판단해서 처리 하면 된다. 

Features/User/Login.cs

using ApiServer.Shared.Interfaces;
using ApiServer.Shared.Models;
using Infrastructure;
using MediatR;

namespace ApiServer.Features.User;

public class Login
{
    public class Command : IRequest<Response>
    {
        public string Id { get; set; } = string.Empty;
        public string Password { get; set; } = string.Empty;
    }
    public class Response
    {
        public bool Result { get; set; }
        public Error? Error { get; set; }
        public string? AccessToken { get; set; }
        public string? RefreshToken { get; set; }
    }

    public class Error
    {
        public string Code { get; set; } = string.Empty;
        public string Message { get; set; } = string.Empty;
    }

    public class CommandHandler : IRequestHandler<Command, Response>
    {
        private readonly ITokenGenerator _tokenGenerator;
        private readonly ApiServerContext _context;
        private readonly IConfiguration _configuration;

        // ITokenGenerator 를 Constructor injection 
        public CommandHandler(ITokenGenerator tokenGenerator, ApiServerContext context, IConfiguration configuration)
        {
            _tokenGenerator = tokenGenerator;
            _context = context;
            _configuration = configuration;
        }
        public async Task<Response> Handle(Command request, CancellationToken cancellationToken)
        {
            var response = new Response { Result = false };
            string? accessToken = null;
            string? refreshToken = null;
            bool isLogin = false;
            Error? error;
            var findUser = Exist(request.Id);
            if (findUser != null)
            {
                isLogin = findUser.Password == request.Password ? true : false;
                if (isLogin)
                {
                    accessToken = _tokenGenerator.Create(request.Id);
                    refreshToken = _tokenGenerator.CreateRefreshToken();
                    AddLogin(request, accessToken, refreshToken);
                    await _context.SaveChangesAsync();
                }
                else
                {
                    error = new Error()
                    {
                        Code = "02",
                        Message = $"Login Failed, check your password"
                    };
                }
            }
            else
            {
                error = new Error()
                {
                    Code = "01",
                    Message = $"can not found LoginId = {request.Id}"
                };
            }
            response.Result = isLogin;
            response.AccessToken = accessToken;
            response.RefreshToken = refreshToken;
            return response;
        }

        private Infrastructure.Models.User? Exist(string id)
        {
            return _context.Users?
            .Where(m => m.Id == id && m.UseFlag == true)
            .SingleOrDefault();
        }

        private void AddLogin(Command item, string? accessToken, string? refreshToken)
        {
            var expirationMinute = _configuration.GetSection("TokenManagement").Get<TokenManagement>()?.RefreshExpiration ?? 1440;

            var refreshTokenExpired = DateTime.UtcNow.AddMinutes(expirationMinute);

            var findUser = _context.Users.Where(u => u.Id == item.Id).SingleOrDefault();
            var login = new Infrastructure.Models.Login
            {
                User = findUser,
                UseFlag = true,
                AccessToken = accessToken ?? string.Empty,
                RefreshToken = refreshToken ?? string.Empty,
                RefreshTokenExpired = refreshTokenExpired
            };
            _context.Logins.Add(login);
        }
    }


}

 

(혹 db 관련 오류가 난다면

최종적으로 작업을 하기 전에 infrastructure/migration 을 삭제 하고

database 에서 apiserver 라는 database 도 삭제 하자

그리고 다시 migration 후 update 해보자)

 

이제 User 를 생성하고 Login 해보자.

id 와 name 에 test 를 넣고 password 에 1234  를 넣었다. (다른값을 넣어도 된다. )

 

이제 login 을 해야 한다. 그런데 id 에 test 를 넣고 password 에 1234 를 넣으면

unauthorized 401 이 표시될 것이다. 

password 는 1234 를 sha256 으로 hash 한 값이기 때문에 그렇다. 

이부분을 해결하기 위해 UserController 에 임시로 PasswordGenerate 라는 api 를 생성할 것이다.

Features/User/UserController.cs

[AllowAnonymous]
[HttpPost("password-generate")]
public async Task<IActionResult> PasswordGenerate(string pasword)
{
    if (!ModelState.IsValid)
        return BadRequest();

    return await Task.FromResult(Ok(EncryptionHashHelper.EncryptPassword(pasword)));
}

swagger 에서 password 에 대한 hash 를 알아오자

Response body 영역에 값을 login 시에 password 에 넣어주면 된다.

 

그리고 Login 해보자

accesstoken 을 Authorize 에 붙혀 넣어서 hello 를 실행해보자

 

자 이제 db 에 값을 넣은것 까지는 완성 되었다. 

이제 이 Token 값들을 가지고 인증에 대한 validation 처리를 직접 해보도록 하자.

 

Shared/Authorization/CustomAuthorizationPolicy.cs

using ApiServer.Shared.Interfaces;
using ApiServer.Shared.Models;
using Infrastructure;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;

namespace ApiServer.Shared.Authorization;

public class CustomAuthorizationPolicyRequirement : IAuthorizationRequirement
{
}
public class CustomAuthorizationPolicyHandler : AuthorizationHandler<CustomAuthorizationPolicyRequirement>
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly ApiServerContext _context;
    private readonly IApiLogger _logger;
    private readonly IConfiguration _configuration;

    public CustomAuthorizationPolicyHandler(IHttpContextAccessor httpContextAccessor, ApiServerContext context,
        IApiLogger logger, IConfiguration configuration)
    {
        _httpContextAccessor = httpContextAccessor;
        _context = context;
        _logger = logger;
        _configuration = configuration;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, CustomAuthorizationPolicyRequirement requirement)
    {
        var token = _httpContextAccessor.HttpContext.Request.Headers["Authorization"].ToString();

        AuthenticationHeaderValue.TryParse(token, out var tokenValue);
        if (await ValidateTokenAsync(tokenValue?.Parameter))
        {
            _logger.LogDebug("authorization succeed");
            context.Succeed(requirement);
        }
        else
        {
            _logger.LogDebug("authorization failed");
            context.Fail();
        }
    }

    private async ValueTask<bool> ValidateTokenAsync(string token)
    {
        try
        {
            var tokenPayload = _configuration.GetSection("TokenManagement").Get<TokenManagement>();
            var handler = new JwtSecurityTokenHandler();
            TokenValidationParameters validationParameters = new TokenValidationParameters
            {
                ValidAudience = tokenPayload?.Audience,
                ValidIssuer = tokenPayload?.Issuer,
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(tokenPayload.Secret))
            };

            var principal = handler.ValidateToken(token, validationParameters, out var jwtToken);

            var identity = (principal.Identity as ClaimsIdentity);
            var nameClaim = identity?.
                            Claims.
                            SingleOrDefault(c => c.Type == "Name");

            return await IsValidationTokenAsync(nameClaim?.Value ?? string.Empty, token);
        }
        catch (Exception e)
        {
            _logger.LogInformation(e.Message);
            return false;
        }
    }

    private async ValueTask<bool> IsValidationTokenAsync(string id, string token)
    {
        var users = await _context.Users.Where(u => u.Id == id && u.UseFlag == true)
                       .Include(u => u.Logins.Where(l => l.AccessToken == token && l.UseFlag == true))
                       .ToListAsync();

        if (users.Count > 0)
        {
            return users.Single().Logins?.Count > 0;
        }

        return false;
    }
}

Program.cs

...
builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
builder.Services.AddScoped<IAuthorizationHandler, CustomAuthorizationPolicyHandler>();
...
//builder.Services.AddAuthorization();
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CustomAuthorizationPolicy", policy =>
    {
        policy.Requirements.Add(new CustomAuthorizationPolicyRequirement());
    });
});

Features/User/UserController.cs

...
[Authorize("CustomAuthorizationPolicy")]
public class UserController : ControllerBase
{
...

 

이제 swagger 를 통해 인증처리(hello 실행) 를 해보면 CustomAuthorizationPolicyHandler 가 실행되는 것을 볼수 있다.

우리가 원하는데로 db 를 통해 token 에 대한 인증을 처리 하는 것이다.

 

이제 logout 을 통해서 token 이 timeout 되기 전에 expire 해보자

 

 

Features/User/Logout.cs

using ApiServer.Shared.Interfaces;
using Infrastructure;
using MediatR;

namespace ApiServer.Features.User;

public class Logout
{
    public class Command : IRequest<Response>
    {
        public string AccessToken { get; set; } = string.Empty;
    }
    public class Response
    {
        public bool Result { get; set; }
        public Error? Error { get; set; }
    }

    public class Error
    {
        public string Code { get; set; } = string.Empty;
        public string Message { get; set; } = string.Empty;
    }

    public class CommandHandler : IRequestHandler<Command, Response>
    {
        private readonly ITokenGenerator _tokenGenerator;
        private readonly ApiServerContext _context;
        private readonly IConfiguration _configuration;

        // ITokenGenerator 를 Constructor injection 
        public CommandHandler(ITokenGenerator tokenGenerator, ApiServerContext context, IConfiguration configuration)
        {
            _tokenGenerator = tokenGenerator;
            _context = context;
            _configuration = configuration;
        }
        public async Task<Response> Handle(Command request, CancellationToken cancellationToken)
        {
            var response = new Response { Result = false };
            var findLogin = Exist(request.AccessToken);
            if (findLogin != null)
            {
                findLogin.UseFlag = false;
                _context.Logins.Update(findLogin);
                await _context.SaveChangesAsync();
            }
            response.Result = true;

            return response;
        }

        private Infrastructure.Models.Login? Exist(string accessToken)
        {
            return _context.Logins?
            .Where(l => l.AccessToken == accessToken)
            .SingleOrDefault();
        }
    }
}

Features/User/UserController.cs

[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
    if (!ModelState.IsValid)
        return BadRequest();

    var token = Request?.Headers["Authorization"];
    AuthenticationHeaderValue.TryParse(token, out var tokenValue);

    var response = await _mediator.Send(new Logout.Command() {AccessToken = tokenValue?.Parameter ?? string.Empty });

    return response.Result ? Ok(response) : Unauthorized();
}

 

이제 swagger 를 통해 Test 해보자

Login 후에 accessToken 을 Authorize 에 설정한 후 hello 를 실행해보자

Logout 한 후 hello 를 실행해보자

 

Logout 후 에는 403 (또는 401) error 가 날 것이다. 

 

AccessToken 을 사용하다가 일정시간(여기서는 30분) 이 지나면 Expired 될것이다. 

사용자가 그때 마다 다시 login 하게 된다면 여간 힘든일이 아닐 수 없다. 

그래서 한번 Login 하고 logout 하기 전에는 RefreshToken 을 이용하여 AccessToken 을 update 해보자

 

이번에는 UserController 에 Refresh 라는 Api 를 추가해 보겠다. 

 

Feature/User/Refresh

using ApiServer.Shared.Interfaces;
using ApiServer.Shared.Models;
using Infrastructure;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace ApiServer.Features.User;

public class Refresh
{
    public class Command : IRequest<Response>
    {
        public string AccessToken { get; set; } = string.Empty;
        public string RefreshToken { get; set; } = string.Empty;
    }

    public class Response
    {
        public string? AccessToken { get; set; }
        public string? RefreshToken { get; set; }
        public bool Result { get; set; }
        public Error? Error { get; set; }
    }

    public class Error
    {
        public string Code { get; set; } = string.Empty;
        public string Message { get; set; } = string.Empty;
    }


    public class CommandHandler : IRequestHandler<Command, Response>
    {
        private readonly ITokenGenerator _tokenGenerator;
        private readonly ApiServerContext _context;
        private readonly IConfiguration _configuration;

        public CommandHandler(ITokenGenerator tokenGenerator, ApiServerContext context, IConfiguration configuration)
        {
            _tokenGenerator = tokenGenerator;
            _context = context;
            _configuration = configuration;
        }

        public async Task<Response> Handle(Command request, CancellationToken cancellationToken)
        {
            var tokenPayload = _configuration.GetSection("TokenManagement").Get<TokenManagement>();
            var handler = new JwtSecurityTokenHandler();
            TokenValidationParameters validationParameters = new TokenValidationParameters
            {
                ValidAudience = tokenPayload.Audience,
                ValidIssuer = tokenPayload.Issuer,
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(tokenPayload.Secret)),
                ValidateLifetime = false
            };

            var principal = handler.ValidateToken(request.AccessToken, validationParameters, out var jwtToken);
            var identity = (principal.Identity as ClaimsIdentity);
            var nameClaim = identity?.
                            Claims.
                            SingleOrDefault(c => c.Type == "Name");

            Infrastructure.Models.Login? findLogin = FindLoginAndCheckValidToken(request, nameClaim);

            Expiring(findLogin);
            (var accessToken, var refreshToken) = Recreate(nameClaim?.Value);

            Add(nameClaim?.Value, accessToken, refreshToken);
            await _context.SaveChangesAsync();

            Response response = new Response();
            response.Result = true;
            response.AccessToken = accessToken;
            response.RefreshToken = refreshToken;
            return response;
        }

        private Infrastructure.Models.Login FindLoginAndCheckValidToken(Command request, Claim? nameClaim)
        {
            var findLogin = Find(request.AccessToken, request.RefreshToken);
            if (findLogin == null) throw new Exception("Invalid refreshToken");
            if (!findLogin.User.Id.Equals(nameClaim?.Value, StringComparison.OrdinalIgnoreCase)) throw new Exception("Invalid refreshToken");
            return findLogin;
        }

        private (string AccessToken, string RefreshToken) Recreate(string id) => (_tokenGenerator.Create(id), _tokenGenerator.CreateRefreshToken());

        private void Expiring(Infrastructure.Models.Login login) => login.UseFlag = false;

        private Infrastructure.Models.Login? Find(string accessToken, string refreshToken)
        {
            return _context.Logins?
            .Where(l => l.AccessToken == accessToken
                    && l.RefreshToken == refreshToken
                    && l.RefreshTokenExpired > DateTime.UtcNow)
            .Include(l => l.User)
            .SingleOrDefault();
        }

        private void Add(string id, string? accessToken, string? refreshToken)
        {
            var expirationMinute = _configuration.GetSection("TokenManagement").Get<TokenManagement>()?.RefreshExpiration ?? 1440;

            var refreshTokenExpired = DateTime.UtcNow.AddMinutes(expirationMinute);

            var findUser = _context.Users.Where(u => u.Id == id).SingleOrDefault();
            var login = new Infrastructure.Models.Login
            {
                User = findUser,
                UseFlag = true,
                AccessToken = accessToken ?? string.Empty,
                RefreshToken = refreshToken ?? string.Empty,
                RefreshTokenExpired = refreshTokenExpired
            };
            _context.Logins.Add(login);
        }
    }

}

 

Feature/User/UserController

[AllowAnonymous]
[ProducesResponseType(typeof(Refresh.Response), StatusCodes.Status200OK)]
[HttpPost("refresh")]
public async Task<IActionResult> Refresh(string refreshToken)
{
    if (!ModelState.IsValid)
        return BadRequest();

    var token = Request?.Headers["Authorization"];
    AuthenticationHeaderValue.TryParse(token, out var tokenValue);

    var response = await _mediator.Send(new Refresh.Command { AccessToken = tokenValue?.Parameter ?? string.Empty, RefreshToken =refreshToken });

    return Ok(response);
}

swagger 를 통해 Refresh 를 실행 시키면 accessToken 과 refreshToken 이 update 되는것을 확인할 수 있다. 

 

관련영상

https://youtu.be/MIb94D9zf10

 

반응형