이전 포스팅까지는 '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서버에서 모든 걸 제어할 수 있으니 원하는 대로 만들기면 하면 되는 겁니다!