2019. 11. 26. 15:30

이전 포스팅까지는 'OAuth2'인증을 위해 별도의 서버를 이용하였습니다.

이렇게 되면 클라이언트에서 인증서버의 주소를 알기 때문에 인증서버를 공격을 할 수 있는 문제가 있습니다.

그리고 인증할 때 추가적인 데이터를 보내기가 힘들다는 문제도 있죠.

 

그래서 이번 포스팅에서는 API를 서버를 통해 인증을 관리하도록 하겠습니다.

 

연관글 영역

 

 

연관글 영역

 

 

API결과 처리를 쉽게 하기 위해 'API 공통 처리'용 모델을 사용합니다.

이 모델에 대한 자세한 내용은 아래 링크를 참고해 주세요.

참고 : [ASP.NET Core] .NET Core로 구현한 SPA(Sigle Page Applications)(3) - API 결과 공통 처리

 

 

 

1. 백엔드(back-end)에서 인증

백엔드(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

 

 

다른 기능(토큰 갱신 등등)도 같은 방식으로 사용할 수 있습니다.

 

 

 

2. API 컨트롤러 만들기

'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
 

 

 

 

2-1. 인증용 함수 만들기

사인인(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

 

 

 

2-2. 엑세스 토큰(Access Token) 갱신용 함수 만들기

엑세스 토큰(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

 

 

 

3. 테스트

이제 테스트를 위해 자바스크립트를 작성해 봅시다.

 

 

3-1. 로그인 테스트

위에서 만든 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

 

 

로그인을 시도해보면 정상적으로 처리되는 것을 볼 수 있습니다.

 

 

 

엑세스 토큰과 리플레시 토큰이 제대로 오는 것이 확인되었습니다.

 

 

3-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
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
 

 

 

 

 

로그인한 후 토큰 갱신 요청을 해봅시다.

 

 

 

토큰 갱신도 잘되고 있네요.

 

 

3-3. 유저 정보 받기

토큰에서 유저 정보를 추출할 수도 있습니다.

 

문제는 엑세스 토큰에서만 받을 수 있고 스코프에 '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서버에서 모든 걸 제어할 수 있으니 원하는 대로 만들기면 하면 되는 겁니다!