2025년 5월 19일 월요일

asp.net 에서 소셜로그인 처리

[서론]

요즘에 웹사이트를 만들때 회원가입 이라던가 이런 부분은 좀 불필요하게 느껴진다.

왜냐면 비밀번호 피로도 때문에 내가 가입한 모든 사이트를 기억할 수 도 없고 

한다고 하더라도 사이트 하나하나 계정 관리하기 엄청 피곤하다.  

그래서 이미 잘알려진 포털 사이트들의 계정으로 서비스할 수 있는 분위기가 형성됐다.

그래서 이번 포스트에서는 Asp.net으로 소셜 로그인을 처리하는 과정을 담았다.

한국에서 제일 유명한 포털 사이트 3곳으로 소셜 로그인 하는 방법을 포스팅 할 것이고

해당 사이트는 (카카오, 네이버, 구글)이다.  한국인 이라면 이 3곳 중 적어도 하나의 

계정은 거의 있을거라 생각한다.  그래서 이걸로 진행함..  ㅇㅇ

그리고 자바스프링은 관련자료가 많이 있지만  Asp.net은 관련자료가 많이 없다.

그런데 안되는건 아니니까..  ㅇㅇ (닷넷 개발자들 홧팅~)



[본론으로 들어가면..]

먼저 각 포털사이트에서 개발자 센터등에서 앱을 등록하고 관련 설정을 해줘야한다.

이 내용은 인터넷에 많이 나와있으니 상세설명은 생략한다. (사진 넘 귀찮 미안..)

단~!! 중요한 부분은 적어도 3개의 값은 반드시 설정하고 알아야한다.

1. ClientID,   2. ClientSecret, 3. RedirectionURL

이 3개의 값은 소셜 로그인을 처리하는데 매우 중요한 정보이다. 


각 포털의 접근 포인트 URL은 아래와 같으며

구글 콘솔 API: https://console.cloud.google.com/

네이버 애플리케이션 센터: https://developers.naver.com/apps/#/list

카카오 애플리케이션 센터: https://developers.kakao.com/


각 사이트별로 특이한 점은

구글은 프로젝트를 생성하고 API 및 서비스의
OAuth 2.0 클라이언트 ID로 접근해서 처리해야하고

네이버는 애플리케이션 등록하고 API설정을 처리해야함 그리고 처음에는 테스트상태

로 진행하며 실배포시에는 네이버 로그인 검수요청을 받아야한다. 그리고 멤버 관리를

통해서 로그인 시도를 해볼수 있는 계정을 늘려서 테스트 해야한다.

카카오는 애플리케이션을 등록하고 카카오 로그인ON처리하고 Redirect URI

설정하고 웹플렛폼 등록을 해야하며 고급 보안에서 ClientSecret을 생성해야한다.

전체적으로 RedirectURL 같은경우는 처음에는 테스트로 보통 진행을 하니까

"https://localhost:포트번호/불라불라"   이런식으로 등록하고 테스트 하면된다.


Asp.net 설정부로 넘어와서..

처리할 전략은 signinmanger를 통해서 소셜로그인을 하고 그 이후에 전달받은

클레임으로 쿠키인증으로 Signin을 하는 방향으로 진행하겠다.

물론 signinmanger가 signin처리 다해주고 db에 접근해서 알아서 처리해주면

좋지만 이건 dbcontext가 반드시 연결되어야하고 지정된 모델로 처리해야만한다.

즉, 편하긴하지만 너무 많이 알아서 해주니까.. 자유도가 낮아지고 예외상황핸들링이 

힘들어진다.  따라서 소셜로그인 이후 정보는 쿠키인증방식을 선택하겠다.

asp.net 사용자들한테는 아무래도 HttpContext.SinginAsync() 로 로그인하는게

훨씬 자연스러우니까..  (나만 그런가.. -_-)


그래서 먼저 nuget에서 설치해야될 패키지는 아래와 같다.

(구글로그인)  Microsoft.AspnetCore.Authentication.Google

(카카오로그인) AspNet.Security.OAuth.KakaoTalk

(네이버로그인) AspNet.Security.OAuth.Naver


이렇게 3개이다.  AspNet.Securiry...  시리즈인경우 Microsoft나 공증된

패키지가 아니니까 의심이 갔지만  사용결과 완전 믿을만하다.

추가적으로 OAuth 로그인의 처리과정을 기술적으로 요약하면 다음과 같음


1. 사용자가 소셜로그인 버튼으로 소셜서버 인가 엔드포인트로 

이동함 이때 ClientID와 요청 Scope그리고 RedirectURL을 함께 전달함

이 리다이렉션에서 Http 302 Found 상태코드가 사용됨.

2. 사용자는 이동된 소셜서버 인가 엔드포인트에 로그인하고 

서비스가 사용할 요청권한에 동의하고 로그인누름(사용자가 동의했음)

3. 소셜 인증서버는 Authorization Code를 미리등록된 RedirectURL로 다시

리다이렉트 시김 이때도 Http 302 사용됨

4. 서비스 백엔드서버는 수신한 인가코드를 인증 서버의 토큰엔드포인트로

보내서 AccessToken과 교환함 이때 사용하는 것이 Client Secret임

5. 서비스 백엔드서버는 발급받은 AccessToken을 이용해 리소스 서버에서

사용자의 정보 (고유 아이디, 이메일, 이름)을 요청하고 응답받음

6. 서비스 백엔드서버는 5번에서 받은 사용자 정보를 바탕으로 자체 서비스에

사용자를 로그인하거나 회원가입처리함.


여기서 서비스 백엔드서버는 내가만든 프로젝트 webapp을 의미한다.

그리고 여기서 중요한 메커니즘은 Http Code 302인데 이 리퀘스트 해더에 

돌아갈 URL을 명시한체로 요청을 보내고 받고 하는 부분이다.

깊게 파고들어서 자기가 스스로 이 과정을 복기하면서 처리할 수 도 있지만

그런식으로 하면 패키지를 사용할 이유는 없음.. (걍 믿어~!!)


아무튼.. 이렇게 처리된 부분을 startup 부분에서 처리하는 건 다음과 같다.

LoginPath나  CallBackPath 이런부분은 자신의 프로젝트에 맞게 변경하고 

스키마이름도 자신의 프로젝트에 맞게 변경한다. Id와 Secret을 설정하는 부분에서

Configuration을 가져오는 부분이 있는데 이건 appsettings.json파일

이런곳에다가 키값을 넣어서 설정한다. (아니면 안전한곳에 저장하던가)

builder.Configuration["불라불라"] 해서 키에 설정된 값을 가져올 수 있음.



    

//Siginin매니저를 위한 기본 IdentityUser 등록
     builder.Services.AddIdentity<IdentityUser, IdentityRole>()
         .AddEntityFrameworkStores<ApplicationDBContext>()
         .AddDefaultTokenProviders();

     //Asp.net용 Authentication (로그인)
     builder.Services.AddAuthentication(options =>
     {
         //어트리뷰트 사용시 기본 검증 스키마 이름
         options.DefaultAuthenticateScheme = "myCookieScheme";
         options.DefaultSignInScheme = "myCookieScheme";
         options.DefaultChallengeScheme = "myCookieScheme";
     })
     .AddCookie("myCookieScheme",
         options =>
         {
             options.Cookie.Name = "myCookie";
             options.LoginPath = "/Account/Login";
             options.AccessDeniedPath = "/Account/Logout";
             options.ExpireTimeSpan = TimeSpan.FromHours(1);
         }
     )
     .AddGoogle(opt => {
         opt.ClientId = builder.Configuration["GoogleAppID"] ?? "";
         opt.ClientSecret = builder.Configuration["GoogleSecret"] ?? "";
         opt.SignInScheme = IdentityConstants.ExternalScheme;
         opt.CallbackPath = "/Account/GoogleAuthCallBack";
        
         //OAuth 인증시 어떤 권한을 포함할지 (지금은 이메일과 이름)
         opt.Scope.Add("openid");
         opt.Scope.Add("https://www.googleapis.com/auth/userinfo.email");
         opt.Scope.Add("https://www.googleapis.com/auth/userinfo.profile");
     })
     .AddNaver(opt => {
         opt.ClientId = builder.Configuration["NaverClientID"];
         opt.ClientSecret = builder.Configuration["NaverClientSecret"];
         opt.SignInScheme = IdentityConstants.ExternalScheme;
         opt.CallbackPath = "/Account/NaverAuthCallBack";
     })
     .AddKakaoTalk(opt => {
         opt.ClientId = builder.Configuration["KakaoRESTAPIKey"];
         opt.ClientSecret = builder.Configuration["KakaoSecretKey"];
         opt.SignInScheme= IdentityConstants.ExternalScheme;
         opt.CallbackPath = "/Account/KakaoAuthCallBack";
     });



이렇게 설정한다.  그 이후 사용자가 소셜 로그인을 처리할 페이지를 만든다. 

그렇게 로그인을 하게 되면 전달 받게되는 정보는 설정값에 따라 다르지만

여기서 중요한 포인트가 있다.

소셜 로그인 처리중 받게 되는 데이터로 사용자를 구분하여야 한다는 것이다.

나는 그 정보를 제일 처음에는 이메일로 처리를 하려고 했다.

하지만 그 이메일 정보는 사용자를 고유하게 식별할 수 없다.

예를들어 카카오 계정에 가입할때 네이버 이메일을 사용할수도 있고 혹은 

그 반대의 경우도 존재한다. 그리고 이메일 정보가 변경될 수도 있다.

따라서 이메일을 가지고 사용자를 구분할 수 없다. 사용자 이름은 더더욱 안되고..

그래서 이런 소셜 로그인을 처리할때 중요한 키값으로 사용되는 정보는 

바로 사용자의 Identifier 이다.  이 값은 사용자를 식별할때 사용하는 곂치지

않는 값이다.  즉  "ClaimTypes.NameIdentifier"  이다.

이값을 가지고 데이터베이스에서 고유하게 처리되어야 할 것이다.

그래서 이 Identifier값과 Name값만 가지고 쿠키로그인을 처리하는 방법은 다음과 같다.

 private SignInManager<IdentityUser> signInManager;

위와 같이 내부멤버로 선언된 signInManager는 DI통해서 생성자로 받으면 된다.

RedirectURL은 프로젝트 설정할때 만든 URL로 바꿔야한다.

로그인후 원래있던곳으로 돌아가려면 인자로 returnUrl을 받아서 Challange할때

properties로 같이 넣어주면 된다.



/// <summary>
/// 구글로 로그인 시도
/// </summary>
[HttpGet]
public IActionResult AttemptGoogleLogin(string returnUrl)
{
    var properties = signInManager.ConfigureExternalAuthenticationProperties(
        "google", "/Account/GoogleAuthCallBack/");

    //returnUrl을 Items에 저장
    properties.Items["returnUrl"] = returnUrl;
    
    //챌린지
    return Challenge(properties, "Google");
}

/// <summary>
/// 구글로 로그인 콜백
/// </summary>
public async Task<IActionResult> GoogleAuthCallBack(string returnUrl)
{
    try
    {
        var loginInfo = await signInManager.GetExternalLoginInfoAsync(); //소셜로그인 사용자정보 받기                
        string actualReturnUrl = Url.Content("~/");
        if (loginInfo == null)
        {
            //승인이 안됐으면 로그인 페이지로 이동 처리
            return RedirectToAction("Login", "Account");
        }
        else
        {
            //승인이 된경우
            var identifierClaim = loginInfo.Principal.Claims.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier);
            var nameClaim = loginInfo.Principal.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Name);

            //로그인처리
            if (identifierClaim != null && nameClaim != null)
            {
                string user_identifier = identifierClaim.Value;
                string user_name = nameClaim.Value;
                string user_domain = "google";

                //Claims처리
                var claims = new List<Claim>
                {                    
                    new Claim("UserIdentifier", user_identifier),
                    new Claim("UserName", user_name),
                    new Claim("UserDomain", user_domain)
                };
                ClaimsPrincipal claimsPrincipa = new ClaimsPrincipal(new ClaimsIdentity(claims, "myCookieScheme"));
                var authProperties = new AuthenticationProperties
                {
                    AllowRefresh = true,            //자동연장 사용함
                    IsPersistent = false,         //세션쿠키만 인정 (브라우저 종료시 쿠키 삭제됨)
                    RedirectUri = "/Account/Login",
                    ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(60)
                };

                //쿠키 인증기반으로 로그인처리
                await HttpContext.SignInAsync("myCookieScheme", claimsPrincipa, authProperties);                        

                //돌아갈곳이 있으면 처리
                actualReturnUrl = loginInfo.AuthenticationProperties?.Items["returnUrl"] ?? Url.Content("~/");
            }
            else
            {
                //클레임이 없으면 로그인으로 이동
                return RedirectToAction("Login", "Account");
            }

            //있던곳으로 돌려줌
            return LocalRedirect(actualReturnUrl);

        }
    } catch (Exception ex) {
        throw;
    }
}



구글의 예시만 들었는데 provider 이름과 스키마 이름만 다를뿐 안의 내용은 같다.

이렇게 처리된 내용을 가지고 로그인 처리후 다음 구현을 진행하면 된다.


ㅇㅇ

씨야..


댓글 없음:

댓글 쓰기