요즘 웹 애플리케이션의 인증은 예전과 다르게 신경 써야 하는 것이.....
1) 모바일 환경으로 인해 아이피가 빈번하게 변경된다.
2) 업데이트나 서버 재시작을 하더라도 인증정보를 유지해 사용자 경험을 해치지 말 것.
외부 API를 지원하려면 OAuth를 구성하는 것이 좋습니다.
참고 : Naver Hello world - OAuth와 춤을
[ASP.NET Core] 빈 프로젝트 세팅 (1) - 'index.html'을 시작페이지로 설정하기
[ASP.NET Core] 빈 프로젝트 세팅 (2) - WebAPI 설정
[ASP.NET Core] .NET Core로 구현한 SPA(Single Page Applications)(1) - 기초
[ASP.NET Core] .NET Core로 구현한 SPA(Single Page Applications)(2) - Ajax공통 기능, 데이터 바인드 처리
[ASP.NET Core] .NET Core로 구현한 SPA(Single Page Applications)(3) - API 결과 공통 처리
[ASP.NET Core] .NET Core로 구현한 SPA(Single Page Applications)(4) - 인증 기능 추가
[ASP.NET Core] .NET Core로 구현한 SPA(Single Page Applications)(5) - 스웨거(Swagger) 설정
인증 기능 추가를 위한 기초적인 공부와 복잡한 구현을 알아보겠습니다.
예전에는 로그인하고 인증정보를 세션에 저장해두는 게 일반적이었습니다.
이 방식의 문제가 서버의 메모리에 인증정보가 저장되다 보니 업데이트를 하면 세션 정보가 날아가면서 사용자들이 다시 로그인해야 한다는 점입니다.
그래서 'OAuth'프로토콜의 경우 토큰(token)을 발급해줍니다.
이 토큰은 서버의 메모리에는 저장되지 않습니다.
보통 쿠키나 클라이언트의 메모리에 저장되어있다가 인증정보가 필요할 때 서버로 보내게 되죠.
그러면 서버는 이 토큰을 분석해서 조건에 맞으면 인증이 되어 있다고 판단하고 동작을 하게 됩니다.
이렇게 되면 서버는 인증정보를 메모리에 저장할 필요가 없어서 자원에 여유가 생기고
유저는 서버가 죽었다 살아나도 인증정보가 유실되지 않아 다시 로그인할 필요가 없어지죠.
그렇다고 서버에서 아무런 정보를 저장할 필요가 없는 건 아닙니다.
만약 토큰을 탈취당한다면?
그래서 일회용 토큰은 엑세스 토큰의 수명은 매우 짧게 제공하고
이 엑세스 토큰의 수명이 다했을 때 사용하는 리플레시 토큰을 따로 발급합니다.
리플레시 토큰도 탈취 가능성이 있어서 주기적으로 갱신해주는 것이 좋겠습니다만......보통은 몇 달 동안 그냥 씁니다 ㅎㅎㅎ
탈취됐다고 생각되면 재발급하거나 직접 로그아웃하면 지우도록 하는 방법 등을 사용합니다.
저장서부터 수명까지 어떻게 관리하고 사용할 것인지는 설계자의 선택입니다.
참고 : [ASP.NET Core 2] OAuth2 인증에서 사용까지 (5) - 인증과 사용 시나리오
[ASP.NET Core 2] OAuth2 인증에서 사용까지 (4) - 'IdentityServer'로 만든 인증서버와 'WebAPI' 같이 사용하기
[ASP.NET Core 2] OAuth2 인증에서 사용까지 (5) - 인증과 사용 시나리오
누겟에서 'IdentityServer4'를 찾아 설치합니다.
우리는 Core 2.2 가 기준이라 2.x 버전을 설치해야 합니다.
누겟에서 'IdentityServer4.AccessTokenValidation' 찾아 설치합니다.
우리는 Core 2.2 가 기준이라 2.x 버전을 설치해야 합니다.
이전 글을 참고하여 'Config.cs'와 'UserServices'폴더의 클래스를 만듭니다.
참고 : [ASP.NET Core 2] OAuth2 인증에서 사용까지 (3) - 'IdentityServer'의 리플레시 토큰(Refresh Token) 사용하기
이전에 만들어놓은 코드가 있다면 'Config.cs'와 'UserServices'는 통으로 복사해서 사용해도 됩니다.
여기서 중요한 건 이번에는 엑세스 토큰을 통해 유저 정보를 불러와야 하므로 스코프에 'openid'가 있어야 합니다.
1 2 3 4 5 6 7 8 9 10 11 | /// <summary> /// 신원 자원 /// </summary> /// <returns></returns> public static List<IdentityResource> GetIdentityResources() { return new List<IdentityResource> { new IdentityResources.OpenId() }; } | 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 | /// <summary> /// 클라이언트 접근 범위를 설정한다. /// </summary> /// <returns></returns> public static IEnumerable<Client> GetClients() { return new List<Client> { new Client { ClientId = "resourceownerclient", AllowedGrantTypes = GrantTypes.ResourceOwnerPasswordAndClientCredentials , AccessTokenType = AccessTokenType.Jwt , AccessTokenLifetime = 60 , 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 = { "dataEventRecords" , IdentityServerConstants.StandardScopes.OpenId , IdentityServerConstants.StandardScopes.OfflineAccess } }//end new Client };//end return } | cs |
참고 : [ASP.NET Core 2] OAuth2 인증에서 사용까지 (3) - 'IdentityServer'의 리플레시 토큰(Refresh Token) 사용하기
유저 정보를 받기 위해 신원 자원을 추가해 줍니다.
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 |
완성된 코드입니다.
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 70 71 72 73 74 75 76 77 78 79 80 | public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } public void ConfigureServices(IServiceCollection services) { //7. OAuth2 미들웨어(IdentityServer) 설정 //AddCustomUserStore : 앞에서 만든 확장메소드를 추가 services.AddIdentityServer() .AddDeveloperSigningCredential() //.AddSigningCredential() .AddExtensionGrantValidator<MyExtensionGrantValidator>() .AddInMemoryApiResources(Config.GetApiResources()) .AddInMemoryClients(Config.GetClients()) .AddInMemoryIdentityResources(Config.GetIdentityResources()) .AddCustomUserStore(); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); //클라이언트 인증 요청 정보 services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }).AddJwtBearer(o => { o.Audience = "apiApp"; //인증서버의 주소 o.Authority = GlobalStatic.AuthUrl; o.RequireHttpsMetadata = false; //인증서버에서 선언한 권한 o.Audience = "dataEventRecords"; }); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { 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.UseAuthentication(); //에러가 났을때 Http 상태코드를 전달하기위한 설정 app.UseStatusCodePages(); app.UseMvc(); } } | cs |
이제는 API에 인증정보가 필요하다면 프론트엔드(front-end)에서 인증정보를 헤더에 붙여서 보내야 합니다.
Ajax를 수정하여 필요하면 헤더를 붙여서 사용할 수 있도록 함수를 수정해야 합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | //헤더에 토큰을 넣기 처리 if (true === bToken) {//헤더 넣기 //모든 옵션은 무조건 입력이 우선이다. //그러니 토큰을 전달할 'authorization'가 있는지 확인한다. if (!jsonOption.headers) { //헤더 옵션 만들기 jsonOption.headers = {}; } if (!jsonOption.headers["authorization"]) { //엑세스 토큰의 변수를 프로젝트에 맞게 수정한다. jsonOption.headers["authorization"] = "Bearer " + GlobalSign.access_token; } } | cs |
'CookieAssist.js'를 생성하고 아래 코드를 넣어 줍니다.
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 | /** * 쿠키 지원 */ var CA = {}; /** 아작스 요청 타입 */ CA.SaveType = { Default : 0 , Month1 : 1 , Year1 : 2 }; /** * 지정한 쿠키를 불러온다. * @param {string} sName 불러올 쿠키 이름 * @returns {string} 쿠키 내용 */ CA.Get = function (sName) { //쿠키를 일단 부른다. var sReturn = $.cookie(sName); if (!sReturn) {//쿠키가 없다. //없으면 빈값을 준다. sReturn = ""; } return sReturn; }; /** * 쿠키 저장 * @param {string} sName 쿠키 이름 * @param {string} sValue 저장할 정보 * @param {type} nType 저장 타입. 'CA.SaveType'기준 */ CA.Set = function (sName, sValue, nType) { var nExpires = 0; switch (nType) { case CA.SaveType.Month1: nExpires = 30; break; case CA.SaveType.Year1: nExpires = 365; break; case CA.SaveType.Default: default: nExpires = 0; break; } //쿠키 저장 $.cookie(sName , sValue , { expires: nExpires }); }; | cs |
제이쿼리 쿠키(jquery-cookie) 유틸을 사용하여 쿠키를 관리합니다.
쿠키 관리에서 자주 쓰는 건 저장과 읽기입니다.
읽을 때는 null 예외처리를 해줍니다,
저장할 때는 저장 기간을 열거형으로 만들어 관리합니다.
엑세스 토큰은 클라이언트의 메모리에 저장되며 그냥 두면 됩니다.
리플레시 토큰은 용도에 따라 메모리에 저장하거나 쿠키에 저장합니다.
자동 사인인 기능이 들어있다면
1) 리플레시 토큰을 쿠키에 저장하고
2) 사용자가 페이지에 접속하면 리플레시 토큰을 이용하여 로그인을 시도합니다.
자동사인인 기능이 없으면 메모리에 저장해두거나
쿠키의 수명을 창을 닫으면 지워지게 해두시면 됩니다.
이 프로젝트에서는 쿠키에 저장해둡니다.
자동저장을 사용하면 리플레시 토큰을 최대 30일까지 보관합니다.
html에 체크 박스를 추가합니다.
어떤 옵션을 활성화했는지는 쿠키에 저장하면 됩니다.
이 기능은 사인인을 시도할 때 정보가 저장됩니다.
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 | //이메일 저장 여부 if (true === objThis.ckAutoSignIn.prop("checked")) {//저장 한다. //이메일 정보 CA.Set(GlobalSign.EmailSave_CookieName , sEmail , CA.SaveType.Year1); //이메일 저장 여부 CA.Set(GlobalSign.EmailSaveIs_CookieName , "true" , CA.SaveType.Year1); } else { //이메일 저장 여부 CA.Set(GlobalSign.EmailSaveIs_CookieName , "false" , CA.SaveType.Year1); } //자동로그인 체크 확인 if (true === objThis.ckAutoSignIn.prop("checked")) {//자동 저장 //자동 저장 여부 여부 CA.Set(GlobalSign.AutoSignIn_CookieName , "true" , CA.SaveType.Year1); } else { //자동 저장 여부 여부 CA.Set(GlobalSign.AutoSignIn_CookieName , "false" , CA.SaveType.Year1); } | 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 | AA.put(false , { url: FS_Api.Sign_SignIn , data: { sEmail: sEmail, sPW: sPW } , success: function (jsonData) { console.log(jsonData); if ("0" === jsonData.infoCode) {//에러 없음 GlobalSign.SignIn = true; GlobalSign.SignIn_token = jsonData.token; GlobalSign.SignIn_ID = sEmail; //엑세스 토큰 저장 GlobalSign.access_token = jsonData.access_token; //자동로그인 여부에 따른 리플레시 수명 지정 if (true === objThis.ckAutoSignIn.prop("checked")) {//자동 저장 //리플레시 토큰 저장 GlobalSign.RefreshToken_Set(jsonData.refresh_token, true); } else { //리플레시 토큰 임시 저장 GlobalSign.RefreshToken_Set(jsonData.refresh_token, false); } alert("사인 인 성공"); //홈으로 이동 Page.Move_Home(); } else {//에러 있음 alert("error code : " + jsonData.infoCode + "\n" + "내용 : " + jsonData.message); } } , error: function (error) { console.log(error); alert("알수 없는 오류가 발생했습니다."); } }); | 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 | /** * 액세스 토큰 갱신 * @param {function} callback 갱신이 성공하면 동작할 콜백 */ AA.RefreshToAccess = function (callback) { var refresh_token = GlobalSign.RefreshToken_Get(); if ("" == refresh_token) {//리플레시 토큰이 없다. //리플레시 토큰이 없으면 토큰을 갱신할 수 없으므로 //로그인이 필요하다. GlobalSign.Move_SignIn_Remove(true, "로그인이 필요합니다."); } else {//있다. //갱신 시도 $.ajax({ type: AA.AjaxType.Put , url: FS_Api.Sign_RefreshToAccess , data: { "sRefreshToken": refresh_token } , dataType: "json" , success: function (jsonResult) { console.log(jsonResult); if (jsonResult.infoCode === "0") {//성공 //받은 토큰 다시 저장 GlobalSign.access_token = jsonResult.access_token; GlobalSign.RefreshToken_SetOption(jsonResult.refresh_token); //요청한 콜백 진행 if (typeof (callback) === "function") { callback(); } } else {//실패 //리플래시 토큰 요청이 실패하면 모든 토큰을 지워야 한다. GlobalSign.Move_SignIn_Remove(true, "로그인이 필요합니다."); } } , error: function (jqXHR, textStatus, errorThrown) { console.log(jqXHR); //리플래시 토큰 요청이 실패하면 모든 토큰을 지워야 한다. GlobalSign.Move_SignIn_Remove(true, "로그인이 필요합니다."); } }); }//end if }; | cs |
엑세스 토큰 사용은 API를 호출할 때 발생하므로 Ajax 공통화 부분에 넣어두면 자동화를 할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | //error함수를 빼오고 var funError = jsonOpt.error; jsonOpt.error = function (jqXHR, textStatus, errorThrown) { //여기에 공통 작업내용을 넣는다. if ((true === bTokenTemp) && (401 === jqXHR.status)) {//엑세스 키 사용 일때 //401에러가 났다. //이 상황은 엑세스 토큰이 없거나 만료된것이다. AA.RefreshToAccess(function () { //엑세스 토큰 갱신이 성공하면 다시 진행 $.ajax(jsonOpt); }); } else { if (funError) { //성공하면 수행할 콜백 funError(jqXHR, textStatus, errorThrown); } } }; | cs |
유저정보는 사인인 할 때 저장하면 됩니다.
그런데 리플레시 토큰으로 사인인하면???
사인인을 하면 유저id와 리프레시 토큰을 저장해야 합니다.
이번 프로젝트에서는 DB를 붙이지 않으니 임시로 저장해둘 리스트를 관리하는 클래스를 만듭니다.
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 | /// <summary> /// 임시로 DB를 대신할 사인인 한 리스트 /// 엔트리 프레임웍이 연결되면 이건 필요없다. /// </summary> public class SignInDB { /// <summary> /// 임시 사인인 리스트 /// </summary> public List<SignInItemModel> SignInItemList { get; set; } public SignInDB() { this.SignInItemList = new List<SignInItemModel>(); } /// <summary> /// 임시 /// 사인인 한 유저의 정보를 리스트에 추가한다. /// 이미 추가되있으면 토큰만 갱신한다. /// </summary> /// <param name="nID"></param> /// <param name="sRefreshToken"></param> public void SignInItemList_Add(long nID, string sRefreshToken) { //아이디나 리플레시 토큰 둘중하나만 같으면 추출한다. //메모리에는 없는데 리플레시토큰을 있을 수 있기 때문. SignInItemModel sim = this.SignInItemList .Where(m => m.ID == nID || m.RefreshToken == sRefreshToken) .FirstOrDefault(); if(sim != null) {//검색된 정보가 있다. //리플레시 토큰 수정 sim.RefreshToken = sRefreshToken; } else {//정보가 없다. //추가한다. sim = new SignInItemModel { ID = nID , RefreshToken = sRefreshToken }; } }//end SignInItemList_Add }//end class GlobalSign | cs |
사인인을 하면 유저 정보를 검색하여 위 리스트에 넣어 줍니다.
여기서 주의해야 할 것이 이 리스트를 가지고 동시접속을 막겠다는 생각은 안 하는 것이 좋습니다.
가능은 하지만 리스트에 있는 유저가 접속돼있는 유저인지 100% 확신할 방법이 없습니다.
동시접속을 막으려면 웹소켓(WebSocket)을 사용하는 것이 좋습니다.
1) 리플레시 토큰으로 엑세스 토큰을 발급하고
2) 이 엑세스 토큰으로 사용자 정보를 불러온 다음
3) 사용자 정보를 저장합니다.
유저 정보 받는 함수는 다음과 같이 받아옵니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | /// <summary> /// 엑세스토큰을 이용하여 유저 정보를 받는다. /// </summary> /// <param name="sAccessToken"></param> /// <returns></returns> private async Task<UserInfoResponse> UserInfoAsync(string sAccessToken) { UserInfoResponse uirUser = await hcAuthClient .GetUserInfoAsync(new UserInfoRequest { Address = this.sIdentityServer4_Url + "connect/userinfo" , Token = sAccessToken, }); return uirUser; } | cs |
'UserInfoResponse.Claims'에 유저 정보가 있습니다.
이 정보를 위에서 만든 리스트에 저장하면 됩니다.
'TestController'에 'Test02'함수를 만들고 인증 필터를 추가하여 인증을 받아야 사용 할 수 있도록 합니다.
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 | [Authorize]//OAuth2 인증 설정 [HttpGet] [Route("Test02")] public ActionResult<TestModel02> Test02(int nData) { //리턴 보조 ApiResultReadyModel armResult = new ApiResultReadyModel(this); //리턴용 모델 TestModel02 tmResult = new TestModel02(); //유저 정보 추출 var identity = (ClaimsIdentity)User.Identity; IEnumerable<Claim> claims = identity.Claims; //유저 네임 추출 Claim claim = claims.FirstOrDefault(m => m.Type == "username"); if (0 <= nData) {//양수다. tmResult.nTest001 = nData; tmResult.sTest002 = "성공 했습니다! : " + claim.Value.ToString(); } else { armResult.StatusCode = StatusCodes.Status500InternalServerError; armResult.infoCode = "1"; armResult.message = "'nData'에 음수가 입력되었습니다."; } return armResult.ToResult(tmResult); } | cs |
이제 프론트엔드에서 사인인을 하고 'Test02'함수를 호출해봅시다.
API 호출도 잘되고 인증정보도 잘 받네요.
완성된 샘플 : Github dang-gun - SPA_NetCore_Foundation/SPA_NetCore_Foundation/SPA_NetCore_Foundation04/
그냥 깃허브의 내용을 보고 사용하시는 것이 좋습니다.