사용에 집중한 포스팅입니다.
프론트 엔드(Front-End) 입장에서 토큰을 어떻게 발급받고 사용하는지 구현해 봅시다.
예제는 자바스크립트에 제이쿼리(jquery)를 사용합니다.
아래와 같이 전역 변수를 선언해줍니다.
1
2
3
4
5
6
7
8
9
|
var sUrl = "https://localhost:44305";
var sApi = "/api/values";
var access_token = "";
var refresh_token = "";
var client_id = "resourceownerclient";
var client_secret = "dataEventRecordsSecret";
var scope = "dataEventRecords offline_access";
|
cs |
이제까지 토큰 발급이라고 했던 부분은 '로그인' 부분입니다.
사용자가 로그인을 시도하면 토큰 발급을 시도하고 발급받은 토큰을 이용하여 서비스를 이용하게 됩니다.
로그인 구조는 다음과 같습니다.
로그인 요청은 무조건 기존 토큰은 무시하고 진행됩니다.
로그인이 성공하면 엑세스 토큰(Access Token)과 리플레시 토큰(Refresh 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
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
|
/**
* 로그인 시도
* @param sID 아이디
* @param sPW 비밀번호
*/
function funcLogin(sID, sPW)
{
//로그인을 시도하여 토큰을 받아온다.
$.ajax({
type: "POST"
, url: sUrl + "/connect/token"
, data: {
"grant_type": "password"
, "client_id": client_id
, "client_secret": client_secret
, "scope": scope
, "username": sID
, "password": sPW
}
, dataType: "json"
, success: function (result) {
console.log(result);
//리턴받은 토큰을 저장한다.
access_token = result.access_token;
refresh_token = result.refresh_token;
}
, error: function (jqXHR, textStatus, errorThrown) {
console.log(jqXHR);
var sReturn = "";
switch (jqXHR.status)
{
case 400:
sReturn += "로그인 실패 : " + jqXHR.responseJSON.error_description + "\n";
sReturn += "실패 사유 : "
if (jqXHR.responseJSON.errorCode === "1")
{//에러코드 1
sReturn += "아이디나 비밀번호가 틀렸습니다\n";
}
else {
sReturn += "알 수 없는 오류\n";
}
break;
default:
sReturn += "알수 없는 오류 : " + jqXHR.status;
break;
}
alert(sReturn);
}
});
}
|
cs |
상세한 오류정보를 커스텀 하여 사용하면 원하는 동작이 가능합니다.
35벌 라인 : 로그인이 실패하면 '400 Bad Request'에러와 함께 커스텀 된 에러가 리턴됩니다.
적절한 처리를 해줍니다.
우리는 필요한 에러 정보를 추가하여 좀 더 정확한 제어를 하도록 할 수 있습니다.
로그인 에러를 커스텀 하려면 'CustomResourceOwnerPasswordValidator.ValidateAsync'에서 'context.Result'를 수정하여 리턴하면 됩니다.
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
|
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);
}
else
{//실패
//실패 코드 작성
Dictionary<string, object> dictError = new Dictionary<string, object>();
dictError.Add("errorCode", "1");
//실패 메시지 전달
context.Result
= new GrantValidationResult(TokenRequestErrors.InvalidGrant
, "invalid credential"
, dictError);
}
return Task.FromResult(0);
}
|
cs |
22번 라인을 보면 추가 정보를 보내기 위해 커스텀 한 딕셔너리(Dictionary)를 전달합니다.
이 정보는 아래처럼 제이썬(json)형태로 전달됩니다.
엑세스 토큰이 만료되면 리플레시 토큰을 이용하여 재발급받아야 합니다.
보통 엑세스 토큰의 만료 시점은 엑세스 토큰을 사용하다가 알게 됩니다.
그러니 엑세스 토큰을 재발급받는 시점은 무언가가 동작하는 중이기 때문에
다시 진행을 하려는 콜백을 받아야 합니다.
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
|
/**
* 액세스 토큰 갱신
* @param callback 갱신이 성공하면 동작할 콜백
*/
function RefreshToAccess(callback)
{
if ("" == refresh_token)
{//리플레시 토큰이 없다.
//리플레시 토큰이 없으면 토큰을 갱신할 수 없으므로
//로그인이 필요하다.
alert("로그인이 필요합니다.");
}
else
{//있다.
//갱신 시도
$.ajax({
type: "POST"
, url: sUrl + "/connect/token"
, data: {
"grant_type": "refresh_token"
, "client_id": client_id
, "client_secret": client_secret
, "scope": scope
, "refresh_token" : refresh_token
}
, dataType: "json"
, success: function (result) {
console.log(result);
//받은 토큰 다시 저장
access_token = result.access_token;
refresh_token = result.refresh_token;
//요청한 콜백 진행
callback();
}
, error: function (jqXHR, textStatus, errorThrown) {
console.log(jqXHR);
//리플래시 토큰 요청이 실패하면 모든 토큰을 지워야 한다.
access_token = "";
refresh_token = "";
alert("로그인이 필요합니다.");
}
});
}//end if
}
|
cs |
재발급이 실패하면 로그인을 다시 해야 합니다.
리플레시 토큰은 로그인 시 발급됩니다.
이 토큰이 없거나 잘못되었다는 것은 로그인을 해야 하는 것을 의미합니다.
41번 라인 : 로그인 페이지로 보내기 전에 토큰 정보를 모두 지워 초기 상태로 만들어 줍니다.
API를 사용하려면 엑세스키가 살아있어야 합니다.
엑세스키가 살아있는지 확인하려면 사용하는 방법뿐이 없습니다.
구조는 아래와 같습니다.
API를 호출했을 때 '401 Unauthorized'에러가 난다면....
리프레시 토큰을 사용하여 엑세스 토큰을 재발급 받아야 합니다.
엑세스 토큰을 재발급받는 것이
실패하면 로그인을 다시 해야 하고,
성공하면 다시 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
|
/**
* API 호출
* @param sUrlTarget API의 주소
* @param sType 요청 방식
* @param jsonData 전달할 데이터
*/
function CallValues(sUrlTarget, sType, jsonData)
{
//변수 백업
var sUrlTarget_backup = sUrlTarget;
var sUrlType_backup = sType;
var jsonData_backup = jsonData;
//api 호출
var funcApiCall = function () {
$.ajax({
type: sUrlType_backup
, url: sUrlTarget_backup
, headers: {
"authorization": "Bearer " + access_token
}
, dataType: "json"
, data: jsonData_backup
, success: function (result) {
console.log(result);
alert("호출 성공 : " + result);
}
, error: function (jqXHR, textStatus, errorThrown) {
console.log(jqXHR);
switch (jqXHR.status)
{
case 401://인증정보 잘못됨.
//여기서 인증정보가 잘못됐다면 엑세스 토큰이 잘못된 것이다.
//엑세스키를 제거해주고
access_token = "";
//다시 API를 호출하여 엑세스 토큰를 갱신한 후
//api를 다시 호출하도록하여 절차가 진행되도록 한다.
CallValues(sUrlType_backup
, sUrlTarget_backup
, jsonData_backup);
break;
default:
alert("알수 없는 오류가 발생하였습니다.");
break;
}//end switch
}
});
};
if ("" == access_token)
{//엑세스 토큰이 없다.
//리플레시 토큰 있는지 확인
if ("" == refresh_token)
{//없다.
alert("로그인이 필요합니다.");
}
else
{//있다.
//토큰 갱신 요청
RefreshToAccess(funcApiCall);
}
}
else
{//엑세스 토큰이 있다.
funcApiCall();
}
}
|
cs |
15번 라인 : API를 호출하는 함수를 정의합니다.
엑세스키가 정상이라면 바로 이 함수를 호출합니다.
39번 라인 : 엑세스 키를 지우고 다시 API를 호출하면
'50번 라인'에 의해 엑세스 토큰을 갱신하는 절차가 실행됩니다.
로그 아웃 구현은 쉽습니다.
1) API 서버에서는 기존 토큰을 지우고
2) 프론트엔드에서는 가지고 있는 토큰 정보를 모두 지우면 됩니다.
토큰을 지우기 위한 주소는 "connect/revocation" 입니다.
원칙대로라면 엑세스 토큰과 리플레시 토큰을 다 지워야 하지만 리플레시 토큰만 지워도 됩니다.
엑세스 토큰은 수명이 짧아서 시간이 지나면 알아서 사라지기 때문입니다.
'AuthController'컨트롤러에 아래 함수를 추가합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
/// <summary>
/// 지정된 토큰 제거
/// </summary>
/// <param name="sRefreshToken"></param>
/// <returns></returns>
private async Task<TokenRevocationResponse> RevocationTokenAsync(string sRefreshToken)
{
//엑세스 토큰도 제거가 가능하지만
//이 시나리오에서는 리플레시 토큰만 제거하면 된다.
TokenRevocationResponse trRequestToken
= await hcAuthClient
.RevokeTokenAsync(new TokenRevocationRequest
{
Address = this.sIdentityServer4_Url + "connect/revocation",
ClientId = "resourceownerclient",
ClientSecret = "dataEventRecordsSecret",
Token = sRefreshToken,
TokenTypeHint = "refresh_token"
});
return trRequestToken;
}
|
cs |
이렇게 지울 리플레시 토큰을 전달하면 바로 만료됩니다.
이제 로그아웃을 할 때 리플레시 토큰을 받아 전달하면 됩니다.
이렇게 하면 리플레시 토큰이 노출되더라도 문제가 되지 않습니다.
프론트 엔드에서는 가지고 있는 토큰을 그냥 지우면 됩니다.
쿠키에 저장했다면 쿠키도 지우면 됩니다.
어차피 가지고 있던 토큰은 만료됐기 때문에
다시 로그인하여 토큰을 받지 않으면 아무 짓도 못합니다.
1
2
|
access_token = "";
refresh_token = "";
|
cs |
완성된 샘플 : Github - OAuth2Sample/OAuth2Sample/UsageScenario/
이제 실제..... 까지는 아니지만, 일반적인 시나리오라고 할 수 있습니다.
실제 샘플은 다른 시리즈에서 다루고 있기 때문에 여기서는 간단하게 이렇게만 다룹니다.
(참고 : [ASP.NET Core] .NET Core로 구현한 SPA(Sigle Page Applications) 기초 - 인증관련, 세션유지 )