이전 포스팅까지는 'OAuth2'인증을 위해 별도의 서버를 이용하였습니다.
이렇게 되면 클라이언트에서 인증서버의 주소를 알기 때문에 인증서버를 공격을 할 수 있는 문제가 있습니다.
그리고 인증할 때 추가적인 데이터를 보내기가 힘들다는 문제도 있죠.
그래서 이번 포스팅에서는 API를 서버를 통해 인증을 관리하도록 하겠습니다.
API결과 처리를 쉽게 하기 위해 'API 공통 처리'용 모델을 사용합니다.
이 모델에 대한 자세한 내용은 아래 링크를 참고해 주세요.
참고 : [ASP.NET Core] .NET Core로 구현한 SPA(Sigle Page Applications)(3) - API 결과 공통 처리
백엔드(back-end)에서 인증하는 방법은 간단합니다.
'HttpClient'를 이용하여 'TokenResponse'를 받으면 됩니다.
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
 | 
 TokenResponse response  
    = await hcAuthClient 
            .RequestPasswordTokenAsync(new PasswordTokenRequest 
{ 
    Address = "https://localhost:44343/connect/token", 
    ClientId = "resourceownerclient", 
    ClientSecret = "dataEventRecordsSecret", 
    Scope = "dataEventRecords offline_access", 
    UserName = user, 
    Password = password 
}); 
 | 
cs | 
다른 기능(토큰 갱신 등등)도 같은 방식으로 사용할 수 있습니다.
'AuthController'를 생성합니다.
다른 컨트롤러에서는 인증을 사용하지 않으니 이 컨트롤러의 지역변수로 'HttpClient'를 선언해 줍니다.
하는 김에 인증서버주소도 선언합니다.
| 
 1 
2 
3 
4 
5 
6 
7 
8 
 | 
 /// <summary> 
/// 인증에 사용할  http클라이언트 
/// </summary> 
private HttpClient hcAuthClient = new HttpClient(); 
/// <summary> 
/// IdentityServer4로 구현된 서버 주소 
/// </summary> 
private string sIdentityServer4_Url = "https://localhost:44343/"; 
 | 
cs | 
사인인(SignIn)을 시도하면 토큰(Token)을 발급하는 '인증용 함수'를 만들어 봅시다.
| 
 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 
 | 
 /// <summary> 
/// 인증서버에 인증을 요청한다. 
/// </summary> 
/// <param name="sID"></param> 
/// <param name="sPassword"></param> 
/// <returns></returns> 
private async Task<TokenResponse> RequestTokenAsync(string sID, string sPassword) 
{ 
    TokenResponse trRequestToken  
        = await hcAuthClient 
                .RequestPasswordTokenAsync(new PasswordTokenRequest 
                { 
                    Address = this.sIdentityServer4_Url + "connect/token", 
                    ClientId = "resourceownerclient", 
                    ClientSecret = "dataEventRecordsSecret", 
                    Scope = "dataEventRecords offline_access", 
                    //유저 인증정보 : 아이디 
                    UserName = sID, 
                    //유저 인증정보 : 비밀번호 
                    Password = sPassword 
                }); 
    return trRequestToken; 
} 
 | 
cs | 
비동기로 인증서버에 인증요청을 보냅니다.
결과는 이 함수에서 판단하지 말고 이 함수를 호출하는 곳에서 처리하도록 합니다.
이 함수를 호출하는 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 
 | 
 [HttpPost] 
[Route("SignIn")] 
public ActionResult<SignInResultModel> SignIn( 
    [FromForm]string sID 
    , [FromForm]string sPW) 
{ 
    //결과용 
    ApiResultReadyModel armResult = new ApiResultReadyModel(this); 
    //로그인 처리용 모델 
    SignInResultModel smResult = new SignInResultModel(); 
    //토큰 요청 
    TokenResponse tr = RequestTokenAsync(sID, sPW).Result; 
    if(true == tr.IsError) 
    {//에러가 있다. 
        armResult.infoCode = "1"; 
        armResult.message = "아이디나 비밀번호가 틀렸습니다."; 
        armResult.StatusCode = StatusCodes.Status401Unauthorized; 
    } 
    else 
    {//에러가 없다. 
        smResult.access_token = tr.AccessToken; 
        smResult.refresh_token = tr.RefreshToken; 
    } 
    return armResult.ToResult(smResult); 
} 
 | 
cs | 
전달받은 'TokenResponse'의 에러가 없다면 약속된 모델을 완성하여 전달합니다.
약속된 모델 'SignInResultModel'은 아래와 와 같습니다.
| 
 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> 
/// 사인인 성공시 전달할 모델 
/// </summary> 
public class SignInResultModel : ApiResultBaseModel 
{ 
    /// <summary> 
    /// 엑세스 토큰 
    /// </summary> 
    public string access_token { get; set; } 
    /// <summary> 
    /// 리플레시 토큰 
    /// </summary> 
    public string refresh_token { get; set; } 
    /// <summary> 
    /// 테스트용 레벨 
    /// </summary> 
    public int Lv { get; set; } 
    public SignInResultModel() 
        : base() 
    { 
        this.access_token = string.Empty; 
        this.refresh_token = string.Empty; 
        this.Lv = 0; 
    } 
} 
 | 
cs | 
엑세스 토큰(Access Token)을 갱신할 때 사용하는 함수를 만들어 봅시다.
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
 | 
 /// <summary> 
/// 액세스 토큰 갱신 
/// </summary> 
/// <param name="sRefreshToken">리플레시토큰</param> 
/// <returns></returns> 
private async Task<TokenResponse> RefreshTokenAsync(string sRefreshToken) 
{ 
    TokenResponse trRequestToken 
        = await hcAuthClient 
                .RequestRefreshTokenAsync(new RefreshTokenRequest 
                { 
                    Address = this.sIdentityServer4_Url + "connect/token", 
                    ClientId = "resourceownerclient", 
                    ClientSecret = "dataEventRecordsSecret", 
                    Scope = "dataEventRecords offline_access", 
                    RefreshToken = sRefreshToken 
                }); 
    return trRequestToken; 
} 
 | 
cs | 
리플레시 토큰(Refresh Token)을 전달하고 결과를 'TokenResponse'로 리턴합니다.
이 함수를 호출하는 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 
 | 
 [HttpPost] 
[Route("RefreshToAccess")] 
public ActionResult<SignInResultModel> RefreshToAccess( 
    [FromForm]string sRefreshToken) 
{ 
    //결과용 
    ApiResultReadyModel armResult = new ApiResultReadyModel(this); 
    //엑세스 토큰 갱신용 모델 
    RefreshToAccessModel smResult = new RefreshToAccessModel(); 
    //토큰 갱신 요청 
    TokenResponse tr = RefreshTokenAsync(sRefreshToken).Result; 
    if (true == tr.IsError) 
    {//에러가 있다. 
        armResult.infoCode = "1"; 
        armResult.message = "토큰 갱신에 실패하였습니다."; 
        armResult.StatusCode = StatusCodes.Status401Unauthorized; 
    } 
    else 
    {//에러가 없다. 
        smResult.access_token = tr.AccessToken; 
        smResult.refresh_token = tr.RefreshToken; 
    } 
    return armResult.ToResult(smResult); 
} 
 | 
cs | 
전달받은 'TokenResponse'의 에러가 없다면 약속된 모델을 완성하여 전달합니다.
약속된 모델 'RefreshToAccessModel'은 아래와 같습니다.
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
 | 
 public class RefreshToAccessModel : ApiResultBaseModel 
{ 
    /// <summary> 
    /// 엑세스 토큰 
    /// </summary> 
    public string access_token { get; set; } 
    /// <summary> 
    /// 리플레시 토큰 
    /// </summary> 
    public string refresh_token { get; set; } 
    public RefreshToAccessModel() 
        : base() 
    { 
        this.access_token = string.Empty; 
        this.refresh_token = string.Empty; 
    } 
} 
 | 
cs | 
이제 테스트를 위해 자바스크립트를 작성해 봅시다.
위에서 만든 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 
 | 
 /** 
 * 로그인 시도 
 * @param sID 아이디 
 * @param sPW 비밀번호 
 */ 
function funcLogin(sID, sPW) 
{ 
    //로그인을 시도하여 토큰을 받아온다. 
    $.ajax({ 
        type: "POST" 
        , url: sUrl + "/api/Auth/SignIn" 
        , data: { 
            "sID": sID 
            , "sPW": sPW 
        } 
        , dataType: "json" 
        , success: function (jsonResult) { 
            console.log(jsonResult); 
            if (jsonResult.infoCode === "0") 
            {//성공 
                //리턴받은 토큰을 저장한다. 
                access_token = jsonResult.access_token; 
                refresh_token = jsonResult.refresh_token; 
            } 
            else 
            {//실패 
                var sReturn = ""; 
                sReturn += "로그인 실패 : " + jsonResult.message + "\n"; 
                sReturn += "실패 사유 : "; 
                switch (jsonResult.infoCode) 
                { 
                    case "1"://에러코드 1 
                        sReturn += "아이디나 비밀번호가 틀렸습니다\n"; 
                        break; 
                    default: 
                        sReturn += "알 수 없는 오류\n"; 
                        break; 
                } 
                alert(sReturn); 
            } 
        } 
        , error: function (jqXHR, textStatus, errorThrown) { 
            console.log(jqXHR); 
        } 
    }); 
} 
 | 
cs | 
로그인을 시도해보면 정상적으로 처리되는 것을 볼 수 있습니다.
엑세스 토큰과 리플레시 토큰이 제대로 오는 것이 확인되었습니다.
엑세스 토큰 갱신도 만들어 봅시다.
| 
 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 
 | 
 /** 
 * 액세스 토큰 갱신 
 * @param callback 갱신이 성공하면 동작할 콜백 
 */ 
function RefreshToAccess(callback) 
{ 
    if ("" == refresh_token) 
    {//리플레시 토큰이 없다. 
        //리플레시 토큰이 없으면 토큰을 갱신할 수 없으므로 
        //로그인이 필요하다. 
        alert("로그인이 필요합니다."); 
    } 
    else 
    {//있다. 
        //갱신 시도 
        $.ajax({ 
            type: "POST" 
            , url: sUrl + "/api/Auth/RefreshToAccess" 
            , data: { 
                "sRefreshToken" : refresh_token 
            } 
            , dataType: "json" 
            , success: function (jsonResult) { 
                console.log(jsonResult); 
                if (jsonResult.infoCode === "0") 
                {//성공 
                    //받은 토큰 다시 저장 
                    access_token = jsonResult.access_token; 
                    refresh_token = jsonResult.refresh_token; 
                    //요청한 콜백 진행 
                    if (typeof(callback) === "function") 
                    { 
                        callback(); 
                    } 
                } 
                else 
                {//실패 
                    //리플래시 토큰 요청이 실패하면 모든 토큰을 지워야 한다. 
                    access_token = ""; 
                    refresh_token = ""; 
                    alert("로그인이 필요합니다."); 
                } 
            } 
            , error: function (jqXHR, textStatus, errorThrown) { 
                console.log(jqXHR); 
                //리플래시 토큰 요청이 실패하면 모든 토큰을 지워야 한다. 
                access_token = ""; 
                refresh_token = ""; 
                alert("로그인이 필요합니다."); 
            } 
        }); 
    }//end if 
} 
 | 
cs | 
로그인한 후 토큰 갱신 요청을 해봅시다.
토큰 갱신도 잘되고 있네요.
토큰에서 유저 정보를 추출할 수도 있습니다.
문제는 엑세스 토큰에서만 받을 수 있고 스코프에 'openid'가 포함되어 있어야 합니다.
그러니 필요하면 넣으세요.
'Config.cs'에 아래 코드를 추가합니다.
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
 | 
 public static List<IdentityResource> GetIdentityResources() 
{ 
    return new List<IdentityResource> 
    { 
        new IdentityResources.OpenId(), 
        new IdentityResources.Profile(), 
        new IdentityResources.Email() 
    }; 
} 
 | 
cs | 
'Startup.cs'의 미들웨어 설정에
| 
 1 
 | 
 .AddInMemoryIdentityResources(Config.GetIdentityResources()) 
 | 
cs | 
를 추가해야 합니다.
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
 | 
 //7. OAuth2 미들웨어(IdentityServer) 설정 
//AddCustomUserStore : 앞에서 만든 확장메소드를 추가 
services.AddIdentityServer() 
    .AddDeveloperSigningCredential() 
    //.AddSigningCredential() 
    .AddExtensionGrantValidator<MyExtensionGrantValidator>() 
    .AddInMemoryApiResources(Config.GetApiResources()) 
    .AddInMemoryClients(Config.GetClients()) 
    .AddInMemoryIdentityResources(Config.GetIdentityResources()) 
    .AddCustomUserStore(); 
 | 
cs | 
로그인 할 때 스코프에 'openid'를 추가해 줍니다.
| 
 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 
 | 
 /// <summary> 
/// 인증서버에 인증을 요청한다. 
/// </summary> 
/// <param name="sID"></param> 
/// <param name="sPassword"></param> 
/// <returns></returns> 
private async Task<TokenResponse> RequestTokenAsync(string sID, string sPassword) 
{ 
    TokenResponse trRequestToken  
        = await hcAuthClient 
                .RequestPasswordTokenAsync(new PasswordTokenRequest 
                { 
                    Address = this.sIdentityServer4_Url + "connect/token", 
                    ClientId = "resourceownerclient", 
                    ClientSecret = "dataEventRecordsSecret", 
                    Scope = "openid dataEventRecords offline_access", 
                    //유저 인증정보 : 아이디 
                    UserName = sID, 
                    //유저 인증정보 : 비밀번호 
                    Password = sPassword 
                }); 
    return trRequestToken; 
} 
 | 
cs | 
이제 유저 정보를 요청하는 함수를 만듭니다.
| 
 1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
 | 
 /// <summary> 
/// 엑세스토큰을 이용하여 유저 정보를 받는다. 
/// </summary> 
/// <param name="sAccessToken"></param> 
/// <returns></returns> 
private async Task<UserInfoResponse> UserInfoAsync(string sAccessToken) 
{ 
    //var discoResponse = await this.discoverDocument(); 
    UserInfoResponse uirUser 
        = await hcAuthClient 
                .GetUserInfoAsync(new UserInfoRequest 
                { 
                    Address = this.sIdentityServer4_Url + "connect/userinfo" 
                    , Token = sAccessToken, 
                }); 
    return uirUser; 
} 
 | 
cs | 
함수를 호출해 봅시다.
값이 잘 나옵니다.
여기서 'openid' 스코프가 없으면 'Forbidden'에러가 나게 됩니다.
완성된 샘플 : Github dang-gun - OAuth2Sample/OAuth2Sample/WebApiAuth/
이 정도 했으면 클라이언트에서 직접 'connect/token'를 호출 못 하게 막는 것이 좋습니다.
(이건 언제 다루게 될지 모르겠습니다.)
이제 API서버에서 모든 걸 제어할 수 있으니 원하는 대로 만들기면 하면 되는 겁니다!