2023. 1. 12. 00:00ㆍASPNET/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 를 알아오자
그리고 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 되는것을 확인할 수 있다.
관련영상
'ASPNET > ASPNET 7' 카테고리의 다른 글
Rest API Template 만들기 - EP 10 (Refactoring - Features) (0) | 2023.01.19 |
---|---|
Rest API Template 만들기 - EP 09 (UnitTest) (0) | 2023.01.16 |
Rest API Template 만들기 - EP 07 (EF Core - Migration and Update) (0) | 2023.01.09 |
Rest API Template 만들기 - EP 06 (Authentication, Authorization with jwt) (0) | 2023.01.05 |
Rest API Template 만들기 - EP 05(AccessToken, RefreshToken with JWT) (0) | 2023.01.02 |