JWT 토큰 커스텀 처리

Json Web Token의 커스텀 처리를 위해 닷넷은 아래 두 개의 네임스페이스를 제공합니다.

System.IdentityModel.Tokens.Jwt Namespace - Microsoft Authentication Library for .NET | Microsoft Learn
image

Microsoft.IdentityModel.JsonWebTokens Namespace - Microsoft Authentication Library for .NET | Microsoft Learn

image

인터넷이나 ChatGpt 가 보여주는 JWT 예제들은 주로 전자에 기반한 것들이 많을 것입니다. 그러나, 전자는 레거시로 후자를 사용하도록 권고하고 있습니다.

후자가 나중에 나온 것이라 그런지, 뭔가 좀 더 정리된 느낌이고, 속도도 빠르다고 합니다.

후자를 바탕으로 JWT 토큰을 처리하는 방법을 알아 봅니다.
토큰의 처리는 크게 생성(Creation)과 검증(Validation)으로 나뉩니다.

생성

생성은 토큰이 가져야 하는 데이터를 제공해서 토큰 문자열을 생성하는 과정으로, 이 도구를 사용할 때는 아래의 두 단계를 따른다고 보시면 됩니다.

  1. 보안 토큰(SecurityToken)을 기술(Describe)
  2. 기술을 바탕으로 실물 토큰 생성

토큰을 기술하기 위해 SecurityTokenDescriptor 객체를 사용합니다.
이 객체를 실물 토큰 핸들러에게 전달하는 방식으로 실물 토큰을 생성합니다.

이 예제는 JWT 토큰을 다루므로, 핸들러는 JsonWebTokenHandler 입니다.

코드

var userId = // ...
var firstName = //...
var lastName = // ....

// Microsoft.IdentityModel.Tokens
var secret = "123456179-123456789-123456789-12";
var keyCode = Encoding.UTF8.GetBytes(secret); 
var symkey = new SymmetricSecurityKey(keyCode);

var signingCredentials = new SigningCredentials(symkey, SecurityAlgorithms.HmacSha256Signature);

var tokenDescriptor =  new SecurityTokenDescriptor
{
   Issuer = "http://YourCompany.Com",
   Expires = DateTime.UtcNow.AddMinutes(120),
   SigningCredentials = signingCredentials,
   Claims = new Dictionary<string, object>()
   {
      ["sub"] = userId,
      ["jti"] = Guid.NewGuid(),
      ["given_name"] = firstName,
      ["family_name"] = lastName,
   }
};

// Microsoft.IdentityModel.JsonWebTokens
tokenDescriptor.Claims = new Dictionary<string, object>()
{
   [JwtRegisteredClaimNames.Sub] = userId,
   [JwtRegisteredClaimNames.Jti] = Guid.NewGuid(),
   [JwtRegisteredClaimNames.GivenName] = firstName,
   [JwtRegisteredClaimNames.FamilyName] = lastName,
};

var jwtHandler = new JsonWebTokenHandler{
   SetDefaultTimesOnTokenCreation = false
};

var tokenString = jwtHandler.CreateToken(tokenDescriptor);

코드에 나타난 주석의 의미는 아래와 같습니다.

Microsoft.IdentityModel.Tokens

보안 토큰과 관련한 도구들을 제공합니다.
여기에는 암호화와 관련한 객체들과, 보안 토큰을 기술하는 SecurityTokenDescriptor 객체가 포함되어 있습니다.

Microsoft.IdentityModel.JsonWebTokens

Json Web Token 과 관련된 도구들을 제공하는데, 대표적인 것이JsonWebTokenHandler 입니다.

이 객체의 CreateToken 메서드에 앞서 설정한 SecurityTokenDescriptor 객체를 제공하면 토큰 문자열을 얻을 수 있습니다.

주의

var secret = "123456179-123456789-123456789-12";
var keyCode = Encoding.UTF8.GetBytes(secret); 
var symkey = new SymmetricSecurityKey(keyCode);

시크릿 값을 ASCII 코드 범위의 문자 32자로 이뤄진 문자열을 설정했는데, 이 문자열을 바탕으로 생성된 대칭키(symKey)는 문자열의 전체 바이트의 합산과 같아, 256 비트가 됩니다.

256 비트 길이의 값을 사용하는 이유는 아래의 코드 때문입니다.

var signingCredentials = new SigningCredentials(
   symkey, 
   SecurityAlgorithms.HmacSha256Signature);

암호화 알고리즘 Sha256을 설정하기 위해 SecurityAlgorithms.HmacSha256Signature 상수를 사용했는데, 이와 유사한 값으로 SecurityAlgorithms.HmacSha256 도 있습니다.

전자는 서명을 위한 Sha256 해시 알고리즘을 의미한다는 차이점이 있습니다.
이 의미에 따라 달라지는 부분은 암호화를 위한 키의 길이(Key size)가 SHA256 의 해시 코드 길이(256)보다 길거나 같아야 함을 강제하는데, 이와 반대로, 키 길이가 256 비트보다 짧으면 에러가 납니다.

Claim, ClaimsIdentity, JsonWebToken

많은 예제들이 토큰을 생성할 때, 이 객체들을 사용하는 것을 봐 왔을 것입니다.
그러나, 이 예제에는 전혀 나타나지 않습니다.

보안 토큰 기술자가 Claims 라는 속성을 가지고 있지만, 이는 사전 객체입니다.

tokenDescriptor.Claims = new Dictionary<string, object>()
// ...

이러한 방식은 코드 입장에서는 매우 효율적입니다.

첫째로, 토큰을 생성하기 위해 아래와 같은 번잡한 생성 과정이 필요 없다는 점이죠.

claim data => Claim => ClaimsIdentity => JsonWebToken => string (JWT)

물론 토큰의 검증에는 위 객체들이 등장합니다.

string (JWT) => JsonWebToken => ClaimsIdentity => Claim

둘째로, 사전의 Value가 object 형식이라, ToString() 메서드를 사용하기 때문에, 값을 코딩하기 위한 번잡함도 줄어 듭니다.

JwtRegisteredClaimNames

Json Web Token 표준에는 몇 가지 예약된 key name 이 있는데, 그 값들을 문자열 상수로 제공합니다.

참고로, IANA 에 등록된 등록된 클래임들은 아래의 사이트에서 확인 가능합니다.
JSON Web Token (JWT) (iana.org)

마지막으로, 예제 코드는 예약된 key name 을 문자열로 한번, 상수 값으로 또 한번 설정하고 있는데, 이는 상수에 매칭되는 문자열 값을 보여주기 위한 것이지, 예제처럼 중복적으로 설정할 필요는 없습니다.

[토큰의 검증으로 이어집니다]

16개의 좋아요

@BigSquare 좋은 설명 고맙습니다, 잘 읽어보겠습니다.

3개의 좋아요

검증

토큰의 검증은 토큰의 발행자와 토큰의 수취인에 따라 의미가 달라집니다.

보통, 토큰의 발행자는 API 서버이고, 수취자는 클라이언트 앱(웹 프론트엔드 앱, 데스크탑 앱, 모바일 앱)입니다.

토큰 수취자(Bearer)

토큰의 수취자가 Json Web Token 에 대해 할 수 있는 일은 두 가지입니다.

  1. 정보의 사용
    토큰의 Payload 에 포함된 Claims 에서 원하는 값을 추출합니다.

  2. 토큰의 위변조 확인
    예제의 토큰은 대칭키 방식인 HS256 알고리즘을 사용했는데, 비밀키(대칭키)를 공개할 수 없기 때문에 수취자 입장에서 토큰의 정보 만으로 토큰의 진위 여부를 확인할 방법은 없습니다.
    그러나, 비 대칭키 방식인 RS256 방식으로 JWT.Signature 를 서명하고, 서명 결과를 JWT.Payload 에 포함시킨 후, 공개키를 별도의 API를 통해 Get 할 수 있게 만들면, 수취자도 검증을 할 수 있게 만들 수 있습니다. (비표준 클래임 명 사용)

"pub_alg" : " rs256",
"pub_key" : "/api/pubkey",
"pub_hash : "{JWT.Signature 를 서버의 비밀키 + RS256 으로 서명한 값}",

그런데, 토큰의 수취자가 토큰의 진위 여부를 검사하는 것은 사실 의미가 없습니다. 어짜피 토큰 제공 시점에 적법한 검증자가 수행한 검증 결과만 중요하기 때문입니다. 일상의 예를 들면, 중고 나라에서 구입한 KTX 표를 내가 검증하는 것은 아무 의미가 없고, 기차역(서버)의 검증을 통과해야만 기차를 탈수 있는 것(서비스)과 같습니다.

수취자 입장에서는 토큰에 포함된 정보를 사용하는 것이 더 중요하다고 할 수 있습니다.

생성 편에서 소개한 도구를 이용해서, 토큰을 사용하는 방법은 아래와 같이 간편합니다.

토큰의 사용

var tokenFromResponse = //...;

var jwt = new JsonWebToken(tokenFromReponse);
        
var userIdString = jwt.Subject;
var validity = jwt.ValidTo;
var isExpired = DateTime.UtcNow > validity; 
        
var firstName = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.GivenName)?.Value;
var lastName = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.FamilyName)?.Value;

tokenFromReponse 은 생성 편의 예제에 의해 생성된 것으로, 응답(Http Response) 바디에서 추출한 것으로 가정한 것입니다. 그러나, JWT 는 표준 형식이기 때문에, JWT 형식만 갖춰지면, 생성 예제가 발급한 토큰이 아니더라도 위의 코드는 정상 동작합니다.

주의해야 할 점은 tokenFromReponse가 오염된 경우(Base64 디코딩 실패 등), JsonWebToken 생성자에서 예외가 발생하기 때문에 try-catch로 처리하는 편이 좋습니다.

토큰 발행자

JWT는 무기명 토큰(Bearer token)이라, 발행된 이후 인터넷을 돌고 돌다가 어느 날 문득 요청에 포함되어 돌아 옵니다. 토큰 발행자 입장에서는 돌아 온 토큰이 자신이 발행한 것이 맞는지 확인(Verification)하는 것이 매우 중요합니다.

확인 하는 방법은 토큰의 Signature 값과 자신이 보유한 비밀키(대칭키)의 해시 값을 비교하는 것입니다.
이를 위해서도 JsonWebTokenHander 를 사용하는데, 생성 시와 마찬가지로 비밀키 값을 제공해야 합니다. 다만, Signing Credentials 에 관한 정보는 토큰의 해더에서 유추할 수 있기 때문에 별도로 제공할 필요는 없습니다.

ClaimsIdentity ValidateToken(string token)
{          
    var secret = "123456179-123456789-123456789-12";
    var symkey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
    
    var validationParameters = new TokenValidationParameters{
        // ValidateIssuerSigningKey = true,
        IssuerSigningKey = symkey,
        ValidateAudience = false,
        ValidIssuer = "http://YourCompany.Com",
        
        // resets clock sckew from 5 minutes(default) to one.
        ClockSkew = TimeSpan.FromMinutes(1),
    };

    var handler = new JsonWebTokenHandler();

    var validationResult = handler.ValidateTokenAsync(token, validationParameters).Result;

    return validationResult.ClaimsIdentity;
}

이 메서드는 서버에서 사용자 인증에 사용한다는 가정 하에, 호출하기 편리하도록 ClaimsIdentity를 반환하게 끔 설계했습니다.

JsonWebTokenHandler 객체에 인증 방식을 규정하는 TokenValidationParameters 객체를 제공하면 토큰의 위변조 검사 결과를 나타내는 TokenValidationResult 객체를 반환하는데, 이 객체에 ClaimsIdentity 가 포함되어 있습니다.

인증에 성공한 경우, TokenValidationResult.ClaimsIdentity.IsAuthenticated = true 로, 설정되고, 실패한 경우, false 로 설정됩니다.

참고로, TokenValidationParameters.IssuerSigningKey 는 자체 발급 토큰이라 대칭키를 사용했지만, 제 3자가 발급한 토큰을 처리하는 경우, 그 발급자가 제공한 공개키를 사용합니다.

위 메서드를 Asp.Net Core 앱의 미들웨어에 사용하면 커스텀 JWT 인증 미들웨어를 정의할 수 있습니다.

public class JwtAuthMiddleware 
{
    private readonly RequestDelegate _next;
    public JwtAuthMiddleware(RequestDelegate next) =>  _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        var token = context.Request.Headers.Authorization.FirstOrDefault()?.Split(" ").Last();
        if (token is not null)
        {
            var claimsIdentity = ValidateToken(token);
            var sub = claimsIdentity.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
            
            if (Guid.TryParse(sub, out var userId))
            {
                // if (_userService.GetById(userId) is not null) 
                context.User = new ClaimsPrincipal(claimsIdentity);
            }  
        }
        await _next(context);
    }

   private ClaimsIdentity ValidateToken(string token)
   { 
   // ...
}
program.cs
// ...
app.UseMiddleware<JwtAuthMiddleware>();
// ...

이 미들웨어는 토큰의 위변조 여부를 검증한 후에, 토큰에 담긴 정보를 바탕으로 사용자를 식별하는데, 사용자 정보는 DB에 있기 때문에 아래의 주석과 같은 처리가 필요합니다.

            if (Guid.TryParse(sub, out var userId))
            {
                // if (_userService.GetById(userId) is not null) 
                context.User = new ClaimsPrincipal(claimsIdentity);
            }  

프레임워크 제공 확장 메서드

커스텀 미들웨어에 포함된 ValidateToken 를 별도의 객체, 예를 들면, JsonWebTokenAuthenticationHandler 에 정의하고, 이를 서비스 컨테이너에 등록하면 이 것이 인증 핸들러가 됩니다. 물론 이 객체는 생성 편에 있는 토큰 생성 로직도 포함해야 겠죠.

interface ITokenAuthenticationHandler 
{
   string CreateToken();
   ClaimsIdentity ValidateToken(string jwtString);
}

이런 방식은 Asp.Net Core 프레임워크의 확장 메서드를 사용하는 방식과 동일합니다.
인증 미들웨어를 UseAuthentication() 로 설정하고, 이 미들웨어가 요구하는 인증 핸들러를 IServiceCollection 에 등록하는 것이죠.

예제를 바탕으로 확장 메서드를 정의해 보면 아래와 같습니다.

program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
   .AddJwtBearer(options => 
   {
      var secret = "123456179-123456789-123456789-12";
      var symkey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
    
      options.TokenValidationParameters = new TokenValidationParameters
      {
         // ValidateIssuerSigningKey = true,
         IssuerSigningKey = symkey,
         ValidateAudience = false,
         ValidIssuer = "http://YourCompany.Com",
        
         // resets clock sckew from 5 minutes(default) to one.
         ClockSkew = TimeSpan.FromMinutes(1),
      };
   });
// ...
// app.UseMiddleware<JwtAuthMiddleware>();
app.UserAuthentication();
// ...

위 방식으로 하든, app.UseMiddleware<JwtAuthMiddleware>()를 사용하든 성공적으로 HttpContext.User 를 설정하는데, 이 값은 Authorization 미들웨어에서 사용합니다.

이는 두 개 중 어떤 방식을 사용해도 Authorization 미들웨어가 정상 동작한다는 의미가 됩니다.

4개의 좋아요