2022. 4. 14. 15:30

이전 포스팅에서 JWT(JSON Web Token)를 발급하고 인증하기 위한 처리를 했습니다.

이 포스팅에서는 이전 포스팅에서 만든 인증 처리를 연결하는 작업을 합니다.

 

[ASP.NET Core 6] OAuth2의 JWT 인증을 구현해 보자 (1/2)
[ASP.NET Core 6] OAuth2의 JWT 인증을 구현해 보자 (2/2)

 

이 시리즈는

'ASP.NET Core 6'에서는 인증 처리를 이런 식으로 하는구나~~~

라는 걸 알려주기 위한 목적이라 설계가 난잡합니다.

 

 

0. 구조

이 프로젝트에서는 'SQLite + Entity Framework'를 사용합니다.

이렇게 구성하면 DB를 사용하지 않을 때는 'InMemory'를 사용하여 메모리DB를 사용할 수 있어 이식성이 좋아서입니다.

 

누겟에서

Microsoft.EntityFrameworkCore

Microsoft.EntityFrameworkCore.Sqlite

Microsoft.EntityFrameworkCore.Tools

Microsoft.AspNetCore.Mvc.NewtonsoftJson

를 찾아 추가해 줍니다.

 

 

0-1. 'DbContext' 만들기

'SQLite'를 사용하는 'DbContext'를 만들어야 합니다.

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public class ModelsDbContext : DbContext
{
    /// <summary>
    /// 
    /// </summary>
#pragma warning disable CS8618 // 생성자를 종료할 때 null을 허용하지 않는 필드에 null이 아닌 값을 포함해야 합니다. null 허용으로 선언해 보세요.
    public ModelsDbContext()
#pragma warning restore CS8618 // 생성자를 종료할 때 null을 허용하지 않는 필드에 null이 아닌 값을 포함해야 합니다. null 허용으로 선언해 보세요.
    {
    }
 
    /// <summary>
    /// 
    /// </summary>
    /// <param name="options"></param>
    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
            
 
        switch (GlobalStatic.DBType)
        {
            case "sqlite":
                options.UseSqlite(GlobalStatic.DBString);
                break;
            case "mysql":
                //options.UseSqlite(GlobalStatic.DBString);
                break;
            case "mssql":
                //options.UseSqlServer(GlobalStatic.DBString);
                break;
 
            case "inmemory":
            default:
                //options.UseInMemoryDatabase("TestDb");
                break;
        }
    }
 
    #region User
    /// <summary>
    /// 유저 사인인 정보
    /// </summary>
    public DbSet<User> User { get; set; }
 
    /// <summary>
    /// 유저 리플레시 토큰
    /// </summary>
    public DbSet<UserRefreshToken> UserRefreshToken { get; set; }
    #endregion
 
    /// <summary>
    /// 
    /// </summary>
    /// <param name="modelBuilder"></param>
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<User>().HasData(
            new User
            {
                idUser = 1,
                PasswordHash = "1111",
                SignName = "test01"
            }
            , new User
            {
                idUser = 2,
                PasswordHash = "1111",
                SignName = "test02"
            });
    }
}
cs

 

'User', 'UserRefreshToken'은 아래에서 만들 겁니다.

 

57줄이 테스트 유저를 추가하는 코드입니다.

패스워드는 원래 암호화를 해야 하지만 여기서는 하지 않습니다.

 

 

0-2. 'UserRefreshToken' 모델 만들기

'UserRefreshToken'은 생성된 리플레시 토큰을 관리하기 위한 테이블입니다.

이 모델은 'ModelsDbContext'의 테이블로 사용됩니다.

 

여기에 들어있지 않은 리플레시토큰은 인증이 불가능합니다.

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/// <summary>
/// 유저 리플레시 토큰
/// </summary>
public class UserRefreshToken
{
    /// <summary>
    /// 유저 리플레시 토큰 고유키
    /// </summary>
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int idUserRefreshToken { get; set; }
 
    /// <summary>
    /// 연결된 유저의 고유키
    /// </summary>
    public int idUser { get; set; }
 
    /// <summary>
    /// 리플레시 토큰
    /// </summary>
    public string RefreshToken { get; set; } = string.Empty;
    
    /// <summary>
    /// 만료 예정 시간
    /// </summary>
    public DateTime ExpiresTime { get; set; }
 
    /// <summary>
    /// 취소된 시간
    /// </summary>
    public DateTime? RevokeTime { get; set; }
 
    /// <summary>
    /// 생성당시 아이피
    /// </summary>
    public string? IpCreated { get; set; }
 
    #region 속성
    /// <summary>
    /// 만료 여부
    /// </summary>
    public bool ExpiredIs { get; set; }
    /// <summary>
    /// 취소 여부
    /// </summary>
    public bool RevokeIs { get; set; }
    /// <summary>
    /// 사용가능 여부
    /// </summary>
    public bool ActiveIs { get; set; }
 
    #endregion
 
    /// <summary>
    /// 이 토큰의 사용가능여부를 다시 확인한다.
    /// </summary>
    public UserRefreshToken ActiveCheck()
    {
        this.ExpiredIs = DateTime.UtcNow >= this.ExpiresTime;
        this.RevokeIs = RevokeTime != null;
        this.ActiveIs = RevokeTime == null && !ExpiredIs;
 
        return this;
    }
}
cs

 

38줄 속성은 이 토큰이 사용할 수 있는 상태인지를 기록해두는 값입니다.

이 값은 57줄 함수에 의해 관리됩니다.

 

57줄 'Linq'를 사용하면 'Linq'안에서 수식 계산을 할 수 없으므로 따로 계산을 해주는 함수입니다.

'Linq'를 사용하지 않는다면 저장할 것 없이 속성을 식으로 만들어서 사용하면 됩니다.

(그 상태로 'Linq'사용하면 에러납니다 ㅎㅎㅎ)

이 함수를 토큰이 살아있는지 확인하기 전에 호출하여야 합니다.

그리고 DB에 저장해야 합니다.

 

59줄 만료 시간이 지났는지 확인하는 코드입니다.

60줄 임의로 만료시켰는지 확인하는 코드입니다.

61줄 이 토큰이 만료되었는지 확인하는 코드입니다.

 

 

0-3. 'User'모델 만들기

'User'모델은 오로지 테스트를 하기 위해 만드는 모델입니다.

이 모델은 'ModelsDbContext'의 테이블로 사용됩니다.

 

유저를 생성하면 이 테이블에 쌓이게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class User
{
    /// <summary>
    /// 유저 고유키
    /// </summary>
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int idUser { get; set; }
 
    /// <summary>
    /// 사인인에 사용되는 이름
    /// </summary>
    /// <remarks>프로젝트에따라 이것이 이름, 이메일 등의 다양한 값이 될 수 있으므로
    /// 네이밍을 이렇게 한다.</remarks>
    public string SignName { get; set; } = string.Empty;
    /// <summary>
    /// 단방향 암호화가된 비밀번호
    /// </summary>
    /// <remarks>이 프로젝트는 최소한으로 구현되기 때문에 암호화를 하지 않는다.</remarks>
    public string PasswordHash { get; set; } = string.Empty;
}
cs

 

 

1. 컨트롤러

이전 프로젝트에서 만들어서 주입한 유틸을 생성자에서 받아서 저장해 둡니다.

이렇게 저장한 유틸을 이 컨트롤러 안에 여기저기서 사용합니다.

 

 

1-1. 생성자

쿠키를 이용할 예정이라 쿠키 처리용 함수도 추가합니다.

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
47
48
49
50
51
52
/// <summary>
/// 사인 관련(인,아웃,조인)
/// </summary>
[Route("api/[controller]/[action]")]
[ApiController]
public class SignController : ControllerBase
{
    public readonly string AccessTokenName = "AccessToken";
    public readonly string RefreshTokenName = "RefreshToken";
 
    /// <summary>
    /// 인증 유틸
    /// </summary>
    private readonly IJwtUtils _JwtUtils;
 
    /// <summary>
    /// 생성자
    /// </summary>
    /// <param name="jwtUtils"></param>
    public SignController(IJwtUtils jwtUtils)
    {
        this._JwtUtils = jwtUtils;
    }
 
    /// <summary>
    /// 쿠키에 엑세스 토큰 저장을 요청한다.
    /// </summary>
    /// <param name="token"></param>
    private void Cookie_AccessToken(string token)
    {
        var cookieOptions = new CookieOptions
        {
            HttpOnly = true,
            Expires = DateTime.UtcNow.AddDays(7)
        };
        Response.Cookies.Append(AccessTokenName, token, cookieOptions);
    }
 
    /// <summary>
    /// 쿠기에 리플레이 토큰 저장을 요청한다.
    /// </summary>
    /// <param name="token"></param>
    private void Cookie_RefreshToken(string token)
    {
        var cookieOptions = new CookieOptions
        {
            HttpOnly = true,
            Expires = DateTime.UtcNow.AddDays(7)
        };
        Response.Cookies.Append(RefreshTokenName, token, cookieOptions);
    }
}
cs

 

생성자의 매개변수는 주입된 서비스나 미들웨어가 일치하는 게 있으면 넘어오게 됩니다.

일치하는 게 없으면 에러 납니다.

이것을 이용하여 넘어온 유틸을 저장해 둡니다.

 

36줄, 50줄에 보면 리스폰스(Response)에 쿠키를 넣습니다.

이렇게 리스폰스는 요청한 클라이언트에 전달되고

클라이언트는 리스폰스의 요청에 따라 쿠키에 해당 정보를 저장해줍니다.

 

 

API 공통 결과 모델(ApiResultModel)

저는 제가 만들어서 사용하고 있는 API결과용 모델과 모듈이 있는데

이 프로젝트에서 그렇게까지 관리할 게 아니기 때문에 간단한 모델을 만들었습니다.

 

이 모델은 선택사항입니다.

1
2
3
4
5
6
7
8
9
10
11
12
public class ApiResultModel
{
    /// <summary>
    /// 성공여부
    /// </summary>
    public bool Complete { get; set; } = false;
 
    /// <summary>
    /// 메시지
    /// </summary>
    public string Message { get; set; } = string.Empty;
}
cs

 

이 모델이 필요한 이유는 예상할 수 있는 오류도 500에러로 전달하는게 싫어서입니다.

예상할 수 있는 오류라면 200을 리턴하고 거기에 따라 UI/UX가 반응하도록 해야 한다는게 제 철학이라 그렇습니다.

 

500에러에 에러 구분용 코드를 별도로 넣어서 리턴하는 방법을 많이 쓰긴 합니다.

 

 

1-2. 가입

가입은 주목해야 할 코드가 없습니다.

 

일반적으로 유저를 추가하는 코드들만 들어가 있을 뿐이죠.

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
47
48
49
50
51
52
53
54
55
56
/// <summary>
/// 가입
/// </summary>
/// <param name="sSignName"></param>
/// <param name="sPassword"></param>
/// <returns></returns>
[HttpPost]
public ActionResult<ApiResultModel> Register(
    [FromForm] string sSignName
    , [FromForm] string sPassword)
{
    //로그인 처리용 모델
    ApiResultModel arReturn = new ApiResultModel();
    arReturn.Complete = true;
 
    if (string.Empty == sSignName)
    {
        arReturn.Complete = false;
        arReturn.Message = "사인인 이름은 필수 입니다.";
    }
    else if (string.Empty == sPassword)
    {
        arReturn.Complete = false;
        arReturn.Message = "사인인 비밀번호는 필수 입니다.";
    }
 
    if (true == arReturn.Complete)
    {
        using (ModelsDbContext db1 = new ModelsDbContext())
        {
            //같은 이름이 있는지 찾는다.
            User? findUser
                = db1.User.Where(w => w.SignName == sSignName)
                    .FirstOrDefault();
 
            if (null != findUser)
            {
                arReturn.Complete = false;
                arReturn.Message = "이미 있는 사인인 이름입니다.";
            }
 
 
            if (true == arReturn.Complete)
            {
                User newUser = new User();
                newUser.SignName = sSignName;
                newUser.PasswordHash = sPassword;
                db1.User.Add(newUser);
            }
 
            db1.SaveChanges();
        }//end using db1
    }
 
    return arReturn;
}
cs

 

 

1-3. 사인인(로그인)

'사인인'에서는 유저가 있다면 리플레시 토큰과 엑세스 토큰을 발급해줍니다.

클라이언트는 이 토큰을 가지고 서버에 인증요청을 하게 되죠.

 

이 프로젝트에서는 API가 직접 쿠키에 접근해 토큰을 기록하고 검색하므로 따로 토큰을 전달하지 않습니다.

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/// <summary>
/// 사인인
/// </summary>
/// <param name="sSignName"></param>
/// <param name="sPassword"></param>
/// <returns></returns>
[HttpPut]
[AllowAnonymous]
public ActionResult<SignInModel> SignIn(
            [FromForm] string sSignName
            , [FromForm] string sPassword)
{
    //로그인 처리용 모델
    SignInModel arReturn = new SignInModel();
 
    DateTime dtNow = DateTime.Now;
 
    using (ModelsDbContext db1 = new ModelsDbContext())
    {
        User? findUser
        = db1.User.Where(w =>
                w.SignName == sSignName
                && w.PasswordHash == sPassword)
        .FirstOrDefault();
 
        if (null != findUser)
        {//유저 찾음
 
            arReturn.idUser = findUser.idUser;
            arReturn.Complete = true;
 
            //엑세스 토큰 생성
            string at = this._JwtUtils.AccessTokenGenerate(findUser.idUser);
            string st = this._JwtUtils.RefreshTokenGenerate();
 
            while (true)
            {
                if (true == db1.UserRefreshToken.Any(a => a.RefreshToken == st))
                {//같은 토큰이 있다.
                    st = this._JwtUtils.RefreshTokenGenerate();
                }
                else
                {
                    //새로운 값이면 완료
                    break;
                }
            }
 
            //기존 토큰 만료 처리
            IQueryable<UserRefreshToken> iqFindURT
                = db1.UserRefreshToken
                    .Where(w => w.idUser == findUser.idUser
                            && true == w.ActiveIs);
            //linq는 데이터를 수정할때는 좋은 솔류션이 아니다.
            //반복문으로 직접수정하는 것이 훨씬 성능에 도움이 된다.
            foreach (UserRefreshToken itemURT in iqFindURT)
            {
                //만료 시간을 기입함
                itemURT.RevokeTime = dtNow;
                itemURT.ActiveCheck();
            }
 
 
            //새로운 토큰 생성
            arReturn.AccessToken = at;
            arReturn.RefreshToken = st;
 
            UserRefreshToken newURT = new UserRefreshToken()
            {
                idUser = findUser.idUser,
                RefreshToken = st,
                ExpiresTime = DateTime.UtcNow.AddSeconds(1296000),
            };
            newURT.ActiveCheck();
 
 
            db1.UserRefreshToken.Add(newURT);
            db1.SaveChanges();
 
            //쿠키에 저장요청
            this.Cookie_AccessToken(at);
            this.Cookie_RefreshToken(st);
        }
    }//end using db1
 
    return arReturn;
}
cs

 

33, 34줄 이전 포스팅에서 만든 유틸을 이용하여 토큰을 생성하는 코드입니다.

 

36줄에서 이 프로젝트에서는 리플레시 토큰이 중복될 수 있으니 생성한 토큰이 중복되었는지 확인하고

중복되었다면 다시 생성합니다.

 

77줄에서 생성한 리플레시 토큰을 DB에 기록합니다.

 

81, 82줄에서 생성한 엑세스 토큰과 리플레시 토큰을 쿠키에 저장해 달라고 요청합니다.

 

 

1-4. 사인아웃(로그아웃)

'사인아웃'은 발급된 액세스 토큰과 리플레시 토큰을 제거하는 역할만 합니다.

리플레시 토큰은 쿠키와 DB에서 제거되고,

액세스 토큰은 쿠키에서 제거됩니다.

 

발급된 액세스 토큰은 임의로 만료시킬 방법이 없습니다.

시크릿 키를 변경하는 방법뿐인데.....

보통 시크릿 키는 사이트별로 하나로 관리하니 이걸 변경하면 사이트의 모든 사람의 액세스 토큰이 만료되게 되죠.

 

그래서 쿠키에서만 지우게 됩니다.

 

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
47
/// <summary>
/// 사인아웃
/// </summary>
/// <remarks>
/// ControllerBase.SignOut가 있어서 new로 선언한다
/// </remarks>
/// <returns></returns>
[HttpPut]
[Authorize]
public new ActionResult<ApiResultModel> SignOut()
{
    ApiResultModel arReturn = new ApiResultModel();
    arReturn.Complete = true;
 
    DateTime dtNow = DateTime.Now;
 
    //대상 유저를 검색하고
    long? idUser = this._JwtUtils.ClaimDataGet(HttpContext.User);
    if (null != idUser)
    {//대상이 있다.
        using (ModelsDbContext db1 = new ModelsDbContext())
        {
            //가지고 있는 기존 리플레시 토큰 만료 처리
            IQueryable<UserRefreshToken> iqFindURT
                = db1.UserRefreshToken
                    .Where(w => w.idUser == idUser
                            && true == w.ActiveIs);
            //linq는 데이터를 수정할때는 좋은 솔류션이 아니다.
            //반복문으로 직접수정하는 것이 훨씬 성능에 도움이 된다.
            foreach (UserRefreshToken itemURT in iqFindURT)
            {
                //만료 시간을 기입함
                itemURT.RevokeTime = dtNow;
                itemURT.ActiveCheck();
            }
 
            db1.SaveChanges();
        }//end using db1
 
        //쿠키에 저장요청
        //빈값을 저장해서 기존 토큰을 제거요청한다.
        this.Cookie_AccessToken("");
        this.Cookie_RefreshToken("");
    }
 
    return arReturn;
}
cs

 

9줄에서 이전 포스팅에서 만든 인증 필수 속성이 필터로 지정했습니다.

 

24줄에서 이 유저의 살아있는 모든 리플레시 토큰을 검색합니다.

만약 같은 유저가 다중 사인인(예를 들면 PC, 모바일 따로 로그인한 경우)을 하였다면 같이 사인 아웃이 됩니다.

그러니 다중플랫폼을 허용한다면 모든 리플레시 토큰이 아닌 지금 사용 중인 리플레시 토큰만 검색하여 제거해야 합니다.

 

33줄에서 검색된 토큰에 만료 시간을 입력하고

34줄에서 사용 가능 여부를 체크하고

37줄에서 DB에 저장합니다.

 

42, 43줄에서 쿠키에 비어있는 값을 넣어 클라이언트는 토큰을 알 수 없게끔 만듭니다.

 

 

1-5. 리플레시 토큰으로 액세스 토큰 발급

엑세스 토큰이 만료되면 이 API를 호출하여 엑세스 토큰을 재발급받아야 합니다.

 

리퀘스트를 통해 클라이언트의 쿠키에 접근하여 리플레시 토큰을 불러올 수 있습니다.

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/// <summary>
/// 리플레시 토큰으로 새로운 액세스 토큰을 생성해준다.
/// </summary>
/// <returns></returns>
[HttpPut]
public ActionResult<SignInModel> RefreshToken()
{
    SignInModel arReturn = new SignInModel();
    arReturn.Complete = true;
 
    DateTime dtNow = DateTime.Now;
 
    //쿠키에서 리플레시토큰을 읽는다.
    string? sST = Request.Cookies[RefreshTokenName];
 
    string sNewST = string.Empty;
    string sNewAT = string.Empty;
 
    if (null != sST && string.Empty != sST)
    {
        using (ModelsDbContext db1 = new ModelsDbContext())
        {
            //리플레시 토큰 검색
            UserRefreshToken? findURT
                = db1.UserRefreshToken
                    .Where(w => w.RefreshToken == sST)
                    .FirstOrDefault();
 
            if (null != findURT
                && true == findURT.ActiveCheck().ActiveIs)
            {//리플레시 토큰 사용가능
 
                User? findUser
                    = db1.User
                        .Where(w => w.idUser == findURT.idUser)
                        .FirstOrDefault();
                if (null != findUser)
                {
 
                    //옵션에 따라 리플레시토큰을 재갱신 해야 한다.
                    sNewST = sST;
                    sNewAT = this._JwtUtils.AccessTokenGenerate(findUser.idUser);
 
                    arReturn.AccessToken = sNewAT;
                    arReturn.RefreshToken = sNewST;
                }
                else
                {
 
                    //없으면 권한 없음 에러를 낸다.
                    arReturn.Complete = false;
                    Response.StatusCode = (int)HttpStatusCode.Unauthorized;
                }
            }
            else
            {
 
                //없으면 권한 없음 에러를 낸다.
                arReturn.Complete = false;
                Response.StatusCode = (int)HttpStatusCode.Unauthorized;
            }
 
            db1.SaveChanges();
 
        }//end using db1
    }
    else
    {//리플레시 토큰이 없다.
 
        //없으면 권한 없음 에러를 낸다.
        arReturn.Complete = false;
        Response.StatusCode = (int)HttpStatusCode.Unauthorized;
    }
 
    //쿠키에 새로운 토큰 저장
    this.Cookie_AccessToken(sNewAT);
    this.Cookie_RefreshToken(sNewST);
 
    return arReturn;
}
cs

 

 

14줄 리퀘스트(Request)를 통해 리플레시 토큰을 받습니다.

 

24줄 DB를 검색하여 이 토큰이 사용 가능한지 확인합니다.

 

33줄 해당 토큰을 가지고 있는 유저를 검색합니다.

 

41줄에서 사용된 리플레시 토큰은 폐기하고 새로 발급하는 것이 일반적입니다.

이 샘플에서는 그렇게까지 관리되지 않으므로 허용한 토큰을 그대로 다시 사용합니다.

 

42줄 검색한 유저 정보를 이용하여 새로운 액세스 토큰을 만듭니다.

 

 

1-6. 리플레시 토큰 강제 만료

엑세스 토큰은 없는데 리플레시 토큰만 있는 경우 리플레시 토큰을 만료시키기 위한 API입니다.

 

리플레시 토큰을 만료시키려면 액세스 토큰을 발급받고 이것으로 API를 요청해야 하는데

엑세스 토큰 없이(사인인) 없이 진행할 수 있도록 '[Authorize]' 속성을 사용하지 않습니다.

 

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
47
/// <summary>
/// 가지고 있는 리플레시 토큰을 만료 시킨다.
/// </summary>
/// <returns></returns>
[HttpDelete]
public ActionResult<ApiResultModel> RefreshTokenRevoke()
{
    ApiResultModel arReturn = new ApiResultModel();
    arReturn.Complete = true;
 
    DateTime dtNow = DateTime.Now;
 
 
    //쿠키에서 토큰을 읽는다.
    string? sST = Request.Cookies[RefreshTokenName];
 
    if (null != sST && string.Empty != sST)
    {//비어있지 않다.
 
        //제거작업
        using (ModelsDbContext db1 = new ModelsDbContext())
        {
            //리플레시 토큰 검색
            List<UserRefreshToken> findURT
                = db1.UserRefreshToken
                    .Where(w => w.RefreshToken == sST)
                    .ToList();
 
            //검색된 리플레시 토큰을 만료 시킨다.
            foreach (UserRefreshToken itemURT in findURT)
            {
                itemURT.RevokeTime = dtNow;
                itemURT.ActiveCheck();
            }
 
            db1.SaveChanges();
        }//end using db1
    }
 
 
    //쿠키에서 토큰 제거
    this.Cookie_AccessToken("");
    this.Cookie_RefreshToken("");
 
 
    return arReturn;
}
cs

 

 

 

2. 클라이언트 만들기

이제 API를 호출할 수 있는 아무 클라이언트를 이용하여 테스트가 가능합니다.

샘플 프로젝트에는 스웨거(Swagger)가 설정되어 있으니 스웨거를 이용해도 됩니다.

 

이 샘플에서는 제이쿼리(JQuery)를 이용하여 API 호출합니다.

 

2-1. Html

간단하게 테스트용 html을 작성합니다.

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
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>ASP.NET Core 6 - Jwt Auth Test</title>
 
    <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
</head>
<body>
    <input type="text" id="txtSignName" value="test01" />
    <input type="password" id="txtPassword" value="1111" /><br />
    <button onclick="SignInCall()">사인인</button><br />
    <button onclick="SignOutCall()">사인 아웃(사인인)</button><br />
    <br />
    <button onclick="RegisterCall()">가입</button><br />
    <br />
    <br />
    <button onclick="SignInfoCall()">사인인포</button><br />
    <br />
    <br />
    <button onclick="RefreshTokenCall()">엑세스 토큰 재요청</button><br />
    <button onclick="RefreshTokenRevokeCall()">리플레시 토큰 제거</button><br />
    <button onclick="RefreshTokenRevokeAllCall()">리플레시 토큰 전체 제거(사인인)</button><br />
</body>
 
</html>
cs

 

위 html을 실행하면 아래와 같이 나옵니다.

 

2-2. 자바스크립트 작성

자바스크립트는 각 API 호출하는 형식으로 만들어져 있습니다.

 

2-2-1. 가입

'Sign/Register'를 호출하는 함수입니다.

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
function RegisterCall()
{
    let sSignName = $("#txtSignName").val();
    let sPassword = $("#txtPassword").val();
 
    if ("" === sSignName)
    {
        alert("사인인 이름을 넣어주세요.");
        return;
    }
    else if ("" === sPassword)
    {
        alert("비밀번호를 넣어주세요.");
        return;
    }
 
    $.ajax({
        url: "api/Sign/Register",
        type: "Post",
        data: {
            sSignName: sSignName,
            sPassword: sPassword,
        },
        success: function (data)
        {
            console.log(data);
 
            if (true === data.Complete)
            {
                alert("가입 되었습니다 : " + sSignName);
            }
            else
            {
                alert(data.Message);
            }
        },
        error: function (error)
        {
            console.log(error);
 
            alert("알수 없는 오류가 발생했습니다.");
 
        }
    });
}
cs

 

 

2-2-2. 사인인

'Sign/SignIn'를 호출하는 함수입니다.

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
function SignInCall()
{
    $.ajax({
        url: "api/Sign/SignIn",
        type: "PUT",
        data: {
            sSignName: $("#txtSignName").val(),
            sPassword: $("#txtPassword").val(),
        },
        success: function (data)
        {
            console.log(data);
            JwtToken = data.AccessToken;
            RefreshToken = data.RefreshToken;
            alert("성공 : " + data.AccessToken);
        },
        error: function (error)
        {
            console.log(error);
 
            alert("알수 없는 오류가 발생했습니다.");
 
        }
    });
}
cs

 

2-2-3. 사인아웃

'Sign/SignOut'을 호출하는 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function SignOutCall()
{
    $.ajax({
        url: "api/Sign/SignOut",
        type: "PUT",
        headers: { "Authorization""bearer " + JwtToken },
        success: function (data)
        {
            console.log(data);
 
            JwtToken = "";
            RefreshToken = "";
 
            alert("성공 : " + data.Complete);
        },
        error: function (error)
        {
            console.log(error);
 
            alert("알수 없는 오류가 발생했습니다.");
 
        }
    });
}
cs

 

2-2-4. 엑세스 토큰 재발급

'Sign/RefreshToken'를 호출하는 함수입니다.

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
function RefreshTokenCall()
{
    $.ajax({
        url: "api/Sign/RefreshToken",
        type: "Put",
        success: function (data)
        {
            console.log(data);
            if (true === data.Complete)
            {
                JwtToken = data.AccessToken;
                RefreshToken = data.RefreshToken;
 
                alert("갱신 성공!");
            }
            else
            {
                alert("사인인을 해주세요");
            }
        },
        error: function (error)
        {
            console.log(error);
 
            alert("알수 없는 오류가 발생했습니다.");
 
        }
    });
}
cs

 

 

2-2-5. 리플레시 토큰 제거

'Sign/RefreshToken'를 호출하는 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function RefreshTokenRevokeCall()
{
    $.ajax({
        url: "api/Sign/RefreshTokenRevoke",
        type: "Delete",
        success: function (data)
        {
            console.log(data);
 
            JwtToken = "";
            RefreshToken = "";
        },
        error: function (error)
        {
            console.log(error);
 
            alert("알수 없는 오류가 발생했습니다.");
 
        }
    });
}
cs

 

 

3. 테스트

이제 버튼을 눌러 테스트를 해봅시다.

사인인을 하면 쿠키에 토큰 정보가 들어가는 것을 확인할 수 있습니다.

 

 

마무리

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

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

 

JWT 발급에만 초점이 맞춰진 샘플이라 여러 가지로 부실하게 많습니다.

좀 더 깊은 내용은 포스팅을 여러 개로 나눠서 해야 할 듯 하네요 ㅎㅎㅎ

 

'JWT'를 발급하고 'OAuth2'가 어떤 식으로 진행되는지 간단하게 알아본 샘플입니다.

핫 포스팅