2022. 4. 12. 15:30

'OAuth2'는 인증방식을 표준화해둔 프로토콜입니다.

이것 밑에 토큰을 발급하고 인증하기 위한 시스템을 만들어 넣어야 하는데....

Json으로 토큰을 발급하고 인증하기 위한 방법의 하나가 'JWT(Json Web Tokens) 인증'입니다.

 

다른 프로젝트에서는 'IdentityServer4'를 이용하여 'JWT'을 발급하고 인증하는 것을 했었는데

이 포스팅에서는 직접 'JWT'를 발급하고 사용하는 방법을 다룹니다.

 

1부는 JWT를 발급하고 확인하는 것을 구현하고

2부에서는 이렇게 만든 JWT 사용하는 방법을 다룹니다.

 

연관글 영역

 

 

0. 프로젝트 생성 및 구성

프로젝트를 'ASP.NET Core'로 생성합니다.

 

'ASP.NET Core'에서 인증처리를 다음과 같은 구조를 가집니다.

 

미들웨어를 주입하면 API요청이 왔을 때 먼저 미들웨어가 요청을 처리하고

그 요청을 컨트롤러의 필터로 넘어가게 됩니다.

 

필터에서 처리할 거 처리하고 컨트롤러로 전달해서

컨트롤러의 API가 동작하게 되죠.

 

이때 토큰을 처리할 클래스 인스턴스를 이용해서 토큰을 생성/관리 하게 됩니다.

(JWT 유틸은 미들웨어나, 어트리뷰트에서도 필요에 따라 사용할 수 있습니다.)

 

 

누겟에서

Microsoft.AspNetCore.Authentication.JwtBearer

추가해줍니다.

 

 

1. 세팅용 모델 만들기

필요한 세팅 정보를 전달하기 위한 모델이 필요합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class JwtAuthSettingModel
{
    /// <summary>
    /// 인증 토큰의 시작 이름 - 실제로 사용되는 값
    /// </summary>
    public string AuthTokenStartName_Complete { get; set; } = "bearer ";
 
    /// <summary>
    /// 엑세스 토큰 생성에 사용될 시크릿 키
    /// </summary>
    /// <remarks>
    /// 이값이 null이거나 비어있으면 자동으로 생성된다.<br />
    /// 자동으로 생성된 값은 프로그램이 실행되는 동안만 유지되므로
    /// 웹사이트를 껏다키면 그전에 생성된 엑세스 토큰은 사용할 수 없게 된다.<br />
    /// <br />
    /// 이 값을 고정해야 웹사이트를 껏다켜는것과 관계없이 엑세스토큰이 유지된다.
    /// </remarks>
    public string? Secret { get; set; }
}
cs

 

시크릿(Secret)은 스크릿 키(Secret Key) 엑세스 토큰을 만들 때 사용할 비밀키를 말합니다.

이 키는 엑세스 토큰을 생성할 때 비밀키로 사용되므로 이 키가 달라지면 기존키를 복원할 수 없게 됩니다.

 

이 세팅값은 전역변수로 선언해도 되는데....

이 프로젝트에서는 서비스에 주입해서 사용해 보겠습니다.

 

'Startup.cs'에서 'ConfigureServices'에 컨피그에 이 모델의 개체를 생성해서 주입해주면 됩니다.

(다른 방법으로 전달해도 됩니다.)

1
2
3
4
5
6
7
8
9
10
public void ConfigureServices(IServiceCollection services)
{
    ...중략...
 
    //Jwt Auth Setting 정보 전달
    //Configuration["JwtSecretSetting:Secret"] = "";
    services.Configure<JwtAuthSettingModel>(Configuration.GetSection("JwtSecretSetting"));
 
    ......
}
cs

 

위 코드는 'appsettings.json'에 있는

'JwtSecretSetting'세션을 전달하는 코드입니다.

('appsettings.json' 파일 다루는 법은 이 포스팅에서 다루지 않습니다.)

 

 

2. 유틸 만들기(Jwt Utils)

제가 참고한 자료에서 유틸이라고 이름을 지어서 그냥 이렇게 쓰는데.....

사실 주입 없이 인스턴스만 가지고 써도 되기 때문에 그냥 개체라고 보는 게 맞습니다.

 

이 클래스는 토큰에 관한 동작을 관리하기 위한 클래스입니다.

서비스에 주입하기 위해 인터페이스를 선언하고 구현채를 따로 만들었을 뿐입니다.

필요하다면 인터페이스 없이 그냥 개체로 선언하여 사용해도 됩니다.

 

인터페이스는 아래와 같이 4개의 함수 원형을 가지고 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public interface IJwtUtils
{
    /// <summary>
    /// 엑세스 토큰 생성
    /// </summary>
    /// <param name="account"></param>
    /// <returns></returns>
    public string AccessTokenGenerate(int idUser);
 
    /// <summary>
    /// 엑세스 토큰 확인.
    /// </summary>
    /// <remarks>미들웨어에서도 호출해서 사용한다.</remarks>
    /// <param name="token"></param>
    /// <returns>찾아낸 idUser</returns>
    public int? AccessTokenValidate(string token);
 
    /// <summary>
    /// 리플레시 토큰 생성.
    /// </summary>
    /// <remarks>중복검사는 하지 않으므로 필요하다면 호출한쪽에서 중복검사를 해야 한다.</remarks>
    /// <returns></returns>
    public string RefreshTokenGenerate();
 
    /// <summary>
    /// HttpContext.User의 클레임을 검색하여 유저 고유정보를 받는다.
    /// </summary>
    /// <param name="claimsPrincipal"></param>
    /// <returns></returns>
    public long? ClaimDataGet(ClaimsPrincipal claimsPrincipal);
}
cs

 

이것을 서비스에 주입하여 다른 곳에서 유틸을 가져다 쓸 수 있도록 해줍시다.

'Startup.cs'에서 'IServiceCollection.AddScoped'를 호출하여 서비스에 주입해줍니다.

1
services.AddScoped<IJwtUtils, JwtUtils>();
cs

여기서 만든 인터페이스와 클래스를 지정해줍니다.

이렇게 하면 'IJwtUtils'를 가져다 쓸 때 자동으로 개체를 생성되어 전달됩니다.

 

 

2-1. 생성자

위 인터페이스를 상속하는 구현 클래스를 만듭니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// 설정된 세팅 정보
/// </summary>
private readonly JwtAuthSettingModel _JwtAuthSetting;
 
public JwtUtils(IOptions<JwtAuthSettingModel> appSettings)
{
    //설정 데이터 받기
    _JwtAuthSetting = appSettings.Value;
 
    if (_JwtAuthSetting.Secret == null 
        || _JwtAuthSetting.Secret == string.Empty)
    {//시크릿 값이 없다.
 
        //새로 생성한다.
        _JwtAuthSetting.Secret 
            = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
    }
}
cs

 

생성자에서는 위에서 만든 세팅용 모델을 전달받아 저장해둡니다.

 

이 코드에서는 들어온 시크릿이 없으면 새로 생성해주는 코드를 넣습니다.

 

 

2-2. 엑세스 토큰 생성

이 함수는 유저의 고유번호를 받아서 엑세스 토큰을 생성해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public string AccessTokenGenerate(int idUser)
{
    //토큰 핸들러
    JwtSecurityTokenHandler tokenHandler 
        = new JwtSecurityTokenHandler();
    //시크릿 키 변환
    byte[] key = Encoding.ASCII.GetBytes(_JwtAuthSetting.Secret!);
    //토큰 설정
    SecurityTokenDescriptor tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(new[] { new Claim("idUser", idUser.ToString()) }),
        //15분 유지
        Expires = DateTime.UtcNow.AddMinutes(15),
        SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
    };
 
    //토큰 생성
    SecurityToken token = tokenHandler.CreateToken(tokenDescriptor);
    return tokenHandler.WriteToken(token);
}
cs

 

주요 포인트는 11줄의 클래임에 "idUser"를 넣어주는 것입니다.

이 엑세스 토큰을 해석했을 때 이 클래임이 배열이 돼서 들어있게 됩니다.

 

7줄에서 시크릿을 바이트 배열로 바꾸고

이것을 14줄에서 사용하여 키를 생성합니다.

 

 

2-3. 액세스 토큰 확인하기

액세스 토큰이 넘어오면 해석하는 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public int? AccessTokenValidate(string token)
{
    if (token == null)
    {
        return null;
    }
 
    //토큰 핸들러
    JwtSecurityTokenHandler tokenHandler 
        = new JwtSecurityTokenHandler();
    byte[] key = Encoding.ASCII.GetBytes(_JwtAuthSetting.Secret!);
    try
    {
        //토큰 유효성 검사 시작
        tokenHandler.ValidateToken(
            token
            , new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = false,
                ValidateAudience = false,
                ClockSkew = TimeSpan.Zero
            }
            , out SecurityToken validatedToken);
 
        //결과 해석
        JwtSecurityToken jwtToken = (JwtSecurityToken)validatedToken;
        int accountId = int.Parse(jwtToken.Claims.First(x => x.Type == "idUser").Value);
        return accountId;
    }
    catch
    {
        //처리중에 오류가 나면 id를 찾지 못했다는 의미이므로
        //null을 리턴시킨다.
        return null;
    }
}
cs

 

15줄에서 토큰 핸들을 세팅하고 

28줄에서 결과를 해석합니다.

29줄에서 클래임중 타입이 "idUser"을 찾습니다.

이것은 미들웨어에서 'idUser'이라는 이름을 가진 클래임에 데이터를 넣기 때문입니다.

 

어떤 이유에서든 여기서 익셉션(Exception)이 발생하면 키가 잘못되었다는 의미입니다.

 

 

2-4. 리플레시 토큰 생성

리플레시 토큰은 정보가 들어 있는 토큰이 아닙니다.

이 토큰을 서버에 저장해 두었다가 이 토큰을 전달받으면 새로운 엑세스 토큰을 발급해주는 방법이죠.

1
2
3
4
5
public string RefreshTokenGenerate()
{
    //랜덤하게 64자리 생성
    return Convert.ToHexString(RandomNumberGenerator.GetBytes(64));
}
cs

 

4번줄의 'RandomNumberGenerator'은 암호학적으로 강력한 바이트 배열을 만들어주는 함수입니다.

이것을 이용하여 토큰을 생성해 줍니다.

 

 

2-5. 클래임에서 유저 고유번호 찾기

'HttpContext'에 입력된 클래임에서 유저 고유번호를 찾아서 리턴해주는 함수입니다.

아직은 'HttpContext'에 클래임이 주입되어 있지 않지만 앞으로 만들 미들웨어에서 주입해 줍니다.

이것을 이용하여 미들웨어와 API, 유틸, 필터 등이 유저의 정보를 전달받습니다.

 

이 함수는 이때 클래임을 찾아 유저 고유번호로 바꿔주는 역할을 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public long? ClaimDataGet(ClaimsPrincipal claimsPrincipal)
{
    //인증정보 확인
    long nUser = 0;
    foreach (Claim item in claimsPrincipal.Claims.ToArray())
    {
        if (item.Type == "idUser")
        {//인증 정보가 있다.
            nUser = Convert.ToInt64(item.Value);
            break;
        }
    }
 
    return nUser;
}
cs

 

'ClaimsPrincipal'이것은 'HttpContext.User'를 전달하면 됩니다.

'ClaimsPrincipal.Claims'여기에 클래임 리스트가 들어 있습니다.

미들웨어에서 'idUser'이라는 이름을 가진 클래임에 데이터를 넣어주기 때문입니다.

 

 

3. 미들웨어 만들기

미들웨어는 'Startup.cs'에서 'app'에 주입하여 사용합니다.

(참고 : MS Docs - ASP.NET Core 미들웨어 )

 

이렇게 주입된 미들웨어는 'HttpContext'에대한 요청이 오면 가로채서 먼저 동작하게 됩니다.

 

미들웨어는 무조건 인보크(Invoke)가 필요합니다.

(Invoke, InvokeAsync 중 하나가 있어야 함)

이 인보크가 요청을 가로채는 역할을 하고,

'RequestDelegate'를 다음 미들웨어(혹은 컨트롤러)에 전달합니다.

 

3-1. 생성자

미들웨어 생성자에서는 인보크에서 진행할 'RequestDelegate'를 전달받아 저장합니다.

1
2
3
4
5
6
7
8
private readonly RequestDelegate _next;
private readonly JwtAuthSettingModel _appSettings;
 
public JwtMiddleware(RequestDelegate next, IOptions<JwtAuthSettingModel> appSettings)
{
    _next = next;
    _appSettings = appSettings.Value;
}
cs

 

위에서 만든 설정 파일도 전달받도록 했지만.....

필요가 없다면 안 받아도 됩니다.

 

 

3-2. 인보크(Invoke)

이 미들웨어는 

1) 위에서 만든 유틸을 전달받아 저장해두었다가

2) 'HttpContext'에 뭔가 요청이 오면 가로채서

3) 헤더에 인증정보가 있는지 확인하고

4) 인증정보가 있으면 이것을 해석하여

5) 'HttpContext'의 클래임에 해석된 정보를 주입하고

넘겨주는 역할을 합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public async Task Invoke(
    HttpContext context
    , IJwtUtils jwtUtils)
{
    //토큰 추출
    string? token 
        = context.Request
            .Headers["Authorization"]
            .FirstOrDefault()?
            .Split(_appSettings.AuthTokenStartName_Complete)
            .Last();
 
    if (null != token)
    {//토큰이 있다.
 
        //토큰에서 idUser 추출
        int? idUser = jwtUtils.AccessTokenValidate(token);
 
 
        if (idUser != null && 0 < idUser)
        {//추출된 아이디가 있다.
 
            //엑세스 토큰에 데이터가 있으면 클레임데이터를 추가해 준다.
            var claims = new List<Claim>
            {
                new Claim("idUser", idUser.ToString()!)
            };
 
            //HttpContext에 클래임 정보를 넣어준다.
            ClaimsIdentity appIdentity = new ClaimsIdentity(claims);
            context.User.AddIdentity(appIdentity);
        }
    }
 
    await _next(context);
}
cs

 

6줄이 'HttpContext'의 헤더에서 인증정보를 추출하기 위한 코드입니다.

여기서는 Linq를 이용하여 추출하고 있는데.....

원하는 대로 넣고 그것에 맞춰 추출하면 됩니다.

 

17줄에서 위에서 만든 유틸에 엑세스 토큰 해석을 맡기게 됩니다.

여기서 유저 정보가 있다면 유저의 고유번호가 넘어오게 됩니다.

 

24줄에서 추출된 유저 정보를 클래임으로 만들고

31줄에서 만든 클래임을 'HttpContext'에 주입합니다.

 

 

4. 어트리뷰트 필터 만들기

컨트롤러의 API는 상황에 따라 인증이 필수일 수 있고 아닐 수 있습니다.

이때 이걸 필터링 할 때 사용하는 것이 속성입니다.

 

컨트롤러나 메소드에 속성을 걸어두면 자동으로 필터링이 되죠.

저는 사용할 때 3개의 속성을 만들지만

보통 2개가 필수라고 보시면 됩니다.

 

 

4-1. 익명 접속 허용 속성(AllowAnonymousAttribute)

별다른 속성을 설정하지 않으면 익명 접속 허용(AllowAnonymous) 상태가 됩니다.

하지만 컨트롤러 자체에 인증 필수 속성(AuthorizeAttribute)을 걸었는데

특정 메소드만 익명접속을 허용해야 할 때가 있는데 이럴 때 사용하는 필터입니다.

 

별다른 코드는 없이 다른 속성을 만들 때 이 속성이 설정돼있는지 확인하는 용도로 사용됩니다.

1
2
3
4
5
6
/// <summary>
/// 인증 스킵 속성
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AllowAnonymousAttribute : Attribute
{ }
cs

 

4-2. 승인 속성(AuthorizeAttribute)

이 속성은 미들웨어에서 주입한 인증정보를 확인하고

인증정보가 있으면 통과시키고,

없으면 '401 Unauthorized'에러를 리턴합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/// <summary>
/// 인증 필수 속성
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorizeAttribute : Attribute, IAuthorizationFilter
{
    /// <summary>
    /// 인증요청이 왔다.
    /// </summary>
    /// <param name="context"></param>
    public void OnAuthorization(AuthorizationFilterContext context)
    {
        //
        bool bAllowAnonymous 
            = context.ActionDescriptor
                    .EndpointMetadata
                    .OfType<AllowAnonymousAttribute>().Any();
        if (true == bAllowAnonymous)
        {//AllowAnonymous으로 설정되어 있다.
 
            //인증을 스킵한다.
            return;
        }
            
 
        //인증정보 확인
        long nUser = 0;
        foreach (Claim item in context.HttpContext.User.Claims.ToArray())
        {
            if (item.Type == "idUser")
            {//인증 정보가 있다.
                nUser = Convert.ToInt64(item.Value);
                break;
            }
        }
 
        if (0 >= nUser)
        {//인증정보가 없다.
            
            //401에러
            context.Result 
                = new JsonResult(new { message = "Unauthorized" }) 
                        { StatusCode = StatusCodes.Status401Unauthorized };
        }
    }
}
cs

 

14줄에서 이 요청을 받은 메소드가 'AllowAnonymousAttribute'속성을 가졌는지 확인합니다.

이 속성이 있다면 인증을 무시하고 통과시킵니다.

 

28줄에서 미들웨어가 주입한 유저 정보를 찾는 코드입니다.

이 샘플에서는 반복문을 사용하고 있는데....

이거 Linq같은 방법을 사용해도 상관없습니다.

 

41줄에서 인증정보가 없으면 '401'에러를 리턴하도록 'AuthorizationFilterContext.Result'를 설정해 줍니다.

 

 

마무리

프로젝트 소스 : github - dang-gun/AspDotNetSamples/WebApi_JwtAuth

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

 

이렇게 간단하게 JWT 토큰을 발급하고 확인하는 방법을 다루었습니다.

제가 참고한 코드에서 가능한 많이 벗어나지 않도록 정리한 거라.....

구조가 좀 이상한 것도 그냥 뒀는데....

이 시리즈에서는 고칠생각이 없습니다 ㅎㅎㅎㅎ