2019. 11. 16. 15:30

전편에서 한번 언급하긴 했었는데....

'GrantTypes'를 'ClientCredentials'로 하면 리플레시 토큰(Refresh Token) 없이 액세스 토큰(Access Token)만 전달됩니다.

이 인증방식은 유저에게 추가 정보를 요청하지 않기 때문에 액세스키가 만료되면 다시 인증요청을 하면 되기 때문입니다.

이런 방식은 B2B(Business-to-Business)에서는 문제가 없는데......

우리에게 필요한건 B2C(Business to Consumer)죠 ㅎㅎ




이번 포스팅에서는 유저 정보를 전달하고 리플레시 토큰을 받아 액세스 토큰을 갱신하는 일반적인 인증방식을 사용할 예정입니다.

'damienbod'님의 블로그를 참고하여 만들었습니다.

참고 : damienbod 님 블로그 - ASP.NET CORE IDENTITYSERVER4 RESOURCE OWNER PASSWORD FLOW WITH CUSTOM USERREPOSITORY



1. 프로젝트 개요

이번 포스팅에서 하려고 하는 시나리오는 

1) 인증서버에 아이디와 비밀번호를 전달하고

2) 액세스 토큰과 리플레시 토큰을 전달받아

3) 액세스 토큰으로 API를 호출 <- 이전 포스팅과 동일

4) 리플레시 토큰으로 액세스토큰 재발급

5) 재발급된 액세스 토큰으로 API를 호출 <- 이전 포스팅과 동일

입니다.


이전 포스팅에서 "2. 'IdentityServer4' 설치"까지 같은 내용입니다.

참고 : [ASP.NET Core] 빈 프로젝트 세팅 (1) - 'index.html'을 시작페이지로 설정하기


1-1. Html 세팅
html은 바디에 안내만 넣습니다.

이 서버의 html은 웹서버가 동작하고 있는지 확인하게 됩니다.


여기에 'JQuery'를 추가하여 토큰을 요청하고 사용하는 코드를 추가합니다.


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
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
 
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"
            integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
            crossorigin="anonymous"></script>
 
    <script>
        var sUrl = "https://localhost:44377/connect/token";
        var access_token = "";
        var refresh_token = "";
 
        /** 토큰 요청 */
        function CallToken()
        {
            $.ajax({
                type: "POST"
                , url: sUrl
                , data: {
                    "grant_type""password"
                    , "client_id""resourceownerclient"
                    , "client_secret""dataEventRecordsSecret"
                    , "scope""dataEventRecords offline_access"
                    , "username""raphael"
                    , "password""raphael"
                }
                , dataType: "json"
                , success: function (result) {
                    console.log(result);
                    access_token = result.access_token;
                    refresh_token = result.refresh_token;
                }
            });
        }
 
        /** 액세스 토큰 갱신 */
        function RefreshToAccess()
        {
            $.ajax({
                type: "POST"
                , url: sUrl
                , data: {
                    "grant_type""refresh_token"
                    , "client_id""resourceownerclient"
                    , "client_secret""dataEventRecordsSecret"
                    , "scope""dataEventRecords offline_access"
                    , "refresh_token" : refresh_token
                }
                , dataType: "json"
                , success: function (result) {
                    console.log(result);
                    access_token = result.access_token;
                    refresh_token = result.refresh_token;
                }
            });
        }
    </script>
</head>
<body>
    이 서버는 인증만 가능합니다.
</body>
</html>
cs



3. 'Config.cs' 수정

여기서 중요한 건 'GrantTypes'을 'ResourceOwnerPasswordAndClientCredentials'로 해주는 것입니다.

이렇게 하면 인증에 유저 정보를 전달해야 합니다.

그러면 액세스 토큰과 리플래시 토큰도 같이 넘어옵니다.


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
/// <summary>
/// 0. 'IdentityServer4' 설정
/// </summary>
public class Config
{
    /// <summary>
    /// API의 인증 범위를 정의한다.
    /// </summary>
    /// <returns></returns>
    public static IEnumerable<ApiResource> GetApiResources()
    {
        return new List<ApiResource>
        {
            new ApiResource("dataEventRecords")
            {
                ApiSecrets = { new Secret("dataEventRecordsSecret".Sha256()) }
                , Scopes = 
                { 
                    new Scope 
                    {
                        Name = "dataeventrecordsscope",
                        DisplayName = "Scope for the dataEventRecords ApiResource"
                    } 
                }
                , UserClaims = { "role""admin""user""dataEventRecords""dataEventRecords.admin""dataEventRecords.user" }
            }//end new ApiResource
        };//end return
    }
 
    /// <summary>
    /// 클라이언트 접근 범위를 설정한다.
    /// </summary>
    /// <returns></returns>
    public static IEnumerable<Client> GetClients()
    {
        return new List<Client>
        {
            new Client
            {
                ClientId = "resourceownerclient",
 
                AllowedGrantTypes = GrantTypes.ResourceOwnerPasswordAndClientCredentials
                , AccessTokenType = AccessTokenType.Jwt
                , AccessTokenLifetime = 3600
                , IdentityTokenLifetime = 3600
                , UpdateAccessTokenClaimsOnRefresh = true
                , SlidingRefreshTokenLifetime = 30
                , AllowOfflineAccess = true
                , RefreshTokenExpiration = TokenExpiration.Absolute
                , RefreshTokenUsage = TokenUsage.OneTimeOnly
                , AlwaysSendClientClaims = true
                , Enabled = true
                , ClientSecrets =  new List<Secret> { new Secret("dataEventRecordsSecret".Sha256()) }
                , AllowedScopes = {
                    IdentityServerConstants.StandardScopes.OfflineAccess
                    , "dataEventRecords"
                }
            }//end new Client
        };//end return
    }
}//end class Config
cs



예제로 사용한 코드에 이것저것 잔뜩 설정이 들어가 있는데.....

있으면 좋은 것들이라 그냥 뒀습니다.

필요한거 뺐다 넣었다 하면서 사용하면 됩니다.


라인 옵션명 설명
46  UpdateAccessTokenClaimsOnRefresh 리플레시 토큰을 사용하여 갱신할 때 액세스토큰을 갱신할지 여부

47

 SlidingRefreshTokenLifetime 리플레시 토큰의 최대 수명.
기본값은 15일입니다.
48  AllowOfflineAccess 리플래시 토큰을 사용할지 여부.
스코프에 'offline_access'를 추가하여 리플래시 토큰을 요청합니다.
49  RefreshTokenExpiration

리플래시 토큰을 갱신할지 여부.

Absolute : 정해진 시점에 만료됩니다.

Sliding : 토큰을 사용하면 시점이 갱신됩니다.

50  RefreshTokenUsage

리플레시 토큰을 사용할때 토큰의 핸들(토큰 값)을 변경할지 여부.

ReUse : 핸들을 다시 사용합니다.

OneTime : 핸들을 변경합니다.

51  AlwaysSendClientClaims 설정된 경우 모든 플로우에 대해 클라이언트 클레임이 전송됩니다.


( 참고 : IdentityServer4 Document - Refresh Tokens,  Client )


인증범위는 임의로 설정하는 것이니 넘어가면 됩니다.



4. 유저 관련 기능

유저 정보를 활용해야 하므로 유저 관련 클래스를 추가하여 기능을 추가합시다.



4-1. 커스텀 유저 모델

유저 정보를 넣기 위한 모델을 추가합니다.


1
2
3
4
5
6
7
8
9
10
/// <summary>
/// 1. 임의 유저 정보 모델
/// </summary>
public class CustomUser
{
    public string SubjectId { get; set; }
    public string Email { get; set; }
    public string UserName { get; set; }
    public string Password { get; set; }
}
cs



4-2. 유저 저장소 인터페이스
'IdentityServerBuilder'에 전달될 인터페이스를 정의합니다.

1
2
3
4
5
6
7
8
9
10
11
12
/// <summary>
/// 2. 유저 저장소 인터페이스
/// 'IdentityServerBuilder'에 전달될 인터페이스
/// </summary>
public interface IUserRepository
{
    bool ValidateCredentials(string username, string password);
 
    CustomUser FindBySubjectId(string subjectId);
 
    CustomUser FindByUsername(string username);
}
cs



4-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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/// <summary>
/// 3. 유저 저장소
/// 유저 정보 및 데이터 엑세스 기능
/// </summary>
public class UserRepository : IUserRepository
{
    /// <summary>
    /// 테스트용 더미 데이터
    /// </summary>
    private readonly List<CustomUser> _users = new List<CustomUser>
    {
        new CustomUser{
            SubjectId = "123",
            UserName = "damienbod",
            Password = "damienbod",
            Email = "damienbod@email.ch"
        },
        new CustomUser{
            SubjectId = "124",
            UserName = "raphael",
            Password = "raphael",
            Email = "raphael@email.ch"
        },
    };
 
    /// <summary>
    /// 인증정보가 확인
    /// </summary>
    /// <param name="username"></param>
    /// <param name="password"></param>
    /// <returns></returns>
    public bool ValidateCredentials(string username, string password)
    {
        var user = FindByUsername(username);
        if (user != null)
        {
            return user.Password.Equals(password);
        }
 
        return false;
    }
 
    /// <summary>
    /// 아이디 검색
    /// </summary>
    /// <param name="subjectId"></param>
    /// <returns></returns>
    public CustomUser FindBySubjectId(string subjectId)
    {
        return _users.FirstOrDefault(x => x.SubjectId == subjectId);
    }
 
    /// <summary>
    /// 이름 검색
    /// </summary>
    /// <param name="username"></param>
    /// <returns></returns>
    public CustomUser FindByUsername(string username)
    {
        return _users.FirstOrDefault(x => x.UserName.Equals(username, StringComparison.OrdinalIgnoreCase));
    }
}
cs



나중에 여기에 DB를 연결한다던가 원하는 관리 방법으로 수정하면 됩니다.



4-4. 커스텀 프로필 서비스

'IdentityServerBuilder'에 전달될 프로필 서비스를 만듭니다.

유효성 검증이 된 경우 토큰에 추가 정보나 요구사항을 추가합니다.


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
/// <summary>
/// 4. 'IdentityServerBuilder'에 전달될 프로필 서비스를 만든다.
/// 유효성 검증이 된경우 토큰에 정보나 요구사항을 추가한다.
/// </summary>
public class CustomProfileService : IProfileService
{
    /// <summary>
    /// 로거
    /// </summary>
    protected readonly ILogger Logger;
 
    /// <summary>
    /// 전달받은 유저 저장소
    /// </summary>
    protected readonly IUserRepository _userRepository;
 
    public CustomProfileService(IUserRepository userRepository, ILogger<CustomProfileService> logger)
    {
        _userRepository = userRepository;
        Logger = logger;
    }
 
    /// <summary>
    /// 서브젝트아이디에 해당하는 정보를 만든다.
    /// 주의 : 직접 참조만 없을뿐이지 실제론 사용된다.
    /// </summary>
    /// <param name="context"></param>
    /// <returns></returns>
    public async Task GetProfileDataAsync(ProfileDataRequestContext context)
    {
        var sub = context.Subject.GetSubjectId();
 
        Logger.LogDebug("Get profile called for subject {subject} from client {client} with claim types {claimTypes} via {caller}",
            context.Subject.GetSubjectId(),
            context.Client.ClientName ?? context.Client.ClientId,
            context.RequestedClaimTypes,
            context.Caller);
 
        var user = _userRepository.FindBySubjectId(context.Subject.GetSubjectId());
 
        var claims = new List<Claim>
        {
            new Claim("role""dataEventRecords.admin"),
            new Claim("role""dataEventRecords.user"),
            new Claim("username", user.UserName),
            new Claim("email", user.Email)
        };
 
        context.IssuedClaims = claims;
    }
 
    /// <summary>
    /// 전달받은 서브젝트아이디가 있는지 확인한다.
    /// 주의 : 직접 참조만 없을뿐이지 실제론 사용된다.
    /// </summary>
    /// <param name="context"></param>
    /// <returns></returns>
    public async Task IsActiveAsync(IsActiveContext context)
    {
        var sub = context.Subject.GetSubjectId();
        var user = _userRepository.FindBySubjectId(context.Subject.GetSubjectId());
        context.IsActive = user != null;
    }
}
cs



4-5. 전달된 유저 정보 유효성 검사

'IResourceOwnerPasswordValidator'를 상속하여 유효성 검사 클래스를 만듭니다.


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
/// <summary>
/// 5. 전달된 유저정보 유효성 검사
/// </summary>
public class CustomResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator
{
    private readonly IUserRepository _userRepository;
 
    public CustomResourceOwnerPasswordValidator(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }
 
    public Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
    {
        if (_userRepository.ValidateCredentials(context.UserName, context.Password))
        {//일치하는 유저 정보가 있다.
 
            //유저 정보 불러오기
            var user = _userRepository.FindByUsername(context.UserName);
 
            //권한 부여 유형을 지정한다.
            context.Result 
                = new GrantValidationResult(user.SubjectId
                                        , OidcConstants.AuthenticationMethods.Password);
        }
 
        return Task.FromResult(0);
    }
}
cs



4-6. 'Microsoft.Extensions.DependencyInjection'에 확장 메소드 추가

'IdentityServerBuilder'에 추가할 종속성을 정의합니다.

위에서 만든 기능들을 추가합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// <summary>
/// 6. 'Microsoft.Extensions.DependencyInjection'에 확장 메소드 추가
/// 'IdentityServerBuilder'에 추가할 종속성을 정의 한다.
/// </summary>
public static class CustomIdentityServerBuilderExtensions
{
    public static IIdentityServerBuilder AddCustomUserStore(this IIdentityServerBuilder builder)
    {
        //사용자 데이터 접근
        builder.Services.AddSingleton<IUserRepository, UserRepository>();
        //토큰에 필요함 클레임 추가
        builder.AddProfileService<CustomProfileService>();
        //사용자 자격 증명 유효성 검사
        builder.AddResourceOwnerValidator<CustomResourceOwnerPasswordValidator>();
 
        return builder;
    }
}
cs



5. 'IdentityServer' 미들웨어 기능 활성화

위에서 만든 컨피그를 로드하여 'IdentityServer'가 OAuth2처리를 하도록 설정합니다.

위에서 만든 확장메소드 추가해 줍니다.


'ConfigureServices'를 다음과 같이 작성합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// <summary>
/// This method gets called by the runtime.
/// Use this method to add services to the container.
/// </summary>
/// <param name="services"></param>
public void ConfigureServices(IServiceCollection services)
{
    //7. OAuth2 미들웨어(IdentityServer) 설정
    //AddCustomUserStore : 앞에서 만든 확장메소드를 추가
    services.AddIdentityServer()
        .AddDeveloperSigningCredential()
        //.AddSigningCredential()
        .AddInMemoryApiResources(Config.GetApiResources())
        .AddInMemoryClients(Config.GetClients())
        .AddCustomUserStore();
 
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}
cs



'Configure'를 다음과 같이 작성합니다.


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
/// <summary>
/// This method gets called by the runtime.
/// Use this method to configure the HTTP request pipeline.
/// </summary>
/// <param name="app"></param>
/// <param name="env"></param>
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
        app.UseHsts();
    }
 
    //09. OAuth2 미들웨어(IdentityServer) CROS 접근 권한 문제
    app.UseCors(options =>
    {
        //전체 허용
        options.AllowAnyOrigin();
    });
    //OAuth2 미들웨어(IdentityServer) 설정
    app.UseIdentityServer();
 
 
    //8. 프로젝트 미들웨어 기능 설정
    //웹사이트 기본파일 읽기 설정
    app.UseDefaultFiles();
    //wwwroot 파일읽기
    app.UseStaticFiles();
    //http요청을 https로 리디렉션합니다.
    //https를 허용하지 않았다면 제거 합니다.
    //https://docs.microsoft.com/ko-kr/aspnet/core/security/enforcing-ssl?view=aspnetcore-3.0&tabs=visual-studio
    app.UseHttpsRedirection();
    //app.UseMvc();
}
cs



37번 라인의 'app.UseHttpsRedirection();'는 http 요청을 강제로 https로 전달합니다.

https가 활성화되지 않았다면 없는 것이 좋습니다.


19번 'app.UseCors'는 외부 접근 권한을 설정하는 함수입니다.

이거 설정 안 하면 외부에서 토큰 요청 시 'CROS'에러가 납니다.


6. 테스트해 보기

html을 만들어놨으므로 함수만 호출해주면 됩니다.


'CallToken()'를 호출해 봅시다.




엑세스 토큰과 리플레시 토큰이 넘어온 것을 확인 할 수 있습니다.


받은 리플레시 토큰이 있으면 'RefreshToAccess()'를 호출하여 액세스 토큰을 갱신 할 수 있습니다.




컨피그 옵션에 따라 리플레시 토큰도 갱신된 것을 알 수 있습니다.



마무리

완성된 샘플 : Github - OAuth2Sample/OAuth2Sample/AuthServer_Custom/


이렇게만 해도 임의로 인증서버를 만들어 사용할 수 있습니다.

사용하는 방법은 바로 이전 포스팅인 '[ASP.NET Core 2] OAuth2 인증에서 사용까지 (2) - 'IdentityServer'를 이용하여 'OAuth2' 인증 받기'와 똑같습니다.

(참고 : [ASP.NET Core 2] OAuth2 인증에서 사용까지 (2) - 'IdentityServer'를 이용하여 'OAuth2' 인증 받기 )


서비스에 따라서 인증서버를 따로 둘 필요는 없습니다.

인증서버와 API서버를 합쳐서 사용해도 됩니다.


  1. Solra 2020.04.28 10:01  Address  Edit/Delete  Reply

    감사합니다 잘보았습니다^^
    파이썬 초보인데 파이썬 함수를 이용해서 받은 리플레시 토큰이 있으면 'RefreshToAccess()'를 호출하여 액세스 토큰을 갱신 하는 코드도 궁금합니다
    파이썬 함수설정해서 리프레시토큰으로 액세스 토큰 갱신하는 코드도 부탁드리고 싶습니다 감사합니다

댓글 작성

이름
패스워드
홈페이지
비밀글