ASP.NET Core Identity를 활용한 구글 로그인(OAuth) 전체 흐름 분석

🔥 ASP.NET Core Identity를 활용한 구글 로그인(OAuth) 전체 흐름 분석

1️⃣ 사용자가 "Google로 로그인" 버튼을 클릭

Navigation.NavigateTo(..., forceLoad: true)

Blazor의 내부 라우팅이 아닌, 브라우저가 직접 해당 URL(api/auth/Challenge/Google...)로 GET 요청보낸다.

csharp
private void LoginWithGoogle()
{
    var returnUrl = "/"; // 로그인 성공 후 돌아올 주소
    var googleLoginUrl = $"/api/auth/Challenge/Google?returnUrl={Uri.EscapeDataString(returnUrl)}";

    // 이 주소로 브라우저가 페이지를 새로고침하며 이동
    Navigation.NavigateTo(googleLoginUrl, forceLoad: true);
}

2️⃣ 백엔드의 인증 시작 (AuthController - Challenge)

csharp
// AuthController.cs
[HttpGet("Challenge/{provider}")]
public IActionResult Challenge(string provider, string? returnUrl = null)
{
    // ...
    var redirectUrl = Url.Action("Callback", "Auth", ...); // 1. Google이 인증 후 돌아올 우리 API 주소(Callback)를 지정
    var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); // 2. 인증 요청에 필요한 속성 구성

    return Challenge(properties, provider); // 3. Google 로그인 페이지로 리디렉션하는 응답 생성
}
  • AuthController.cs의 Challenge 메서드가 실행됩니다. provider 매개변수에는 "Google"이 전달
  • _signInManager는 ASP.NET Core Identity의 핵심 기능으로, 외부 로그인 과정을 도와줌
  • return Challenge(properties, provider);는 브라우저에게 HTTP 302 Redirect 응답을 보냄 이 응답 헤더에는 사용자가 이동해야 할 Google의 로그인 페이지 주소가 포함

3️⃣ 외부 공급자 인증 (Google)

사용자는 Google 로그인 페이지로 이동

  • 사용자는 자신의 Google 계정으로 로그인하고, 우리 애플리케이션이 요청하는 정보(이메일, 프로필 등)에 대한 접근 권한을 허용
  • 인증이 성공적으로 완료되면, Google은 2단계에서 백엔드가 지정했던 콜백 URL(api/Auth/Callback)로 사용자를 다시 리디렉션 이때 인증 코드 또는 토큰과 함께 사용자를 보냄

4️⃣ 백엔드의 콜백 처리 및 로그인/회원가입 (AuthController - Callback)

Google이 사용자를 우리 백엔드의 Callback 엔드포인트로 돌려보냄

AuthController.cs의 Callback (라우팅 경로: externalLogin) 메서드가 실행

csharp
// AuthController.cs
[HttpGet("externalLogin")]
public async Task<IActionResult> Callback(...)
{
    // 1. Google이 보내준 정보로 외부 로그인 정보를 가져옴
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null) { /* 에러 처리 */ }

    // 2. 이 외부 정보(공급자, 키)로 우리 DB에 연결된 사용자가 있는지 확인하고 로그인 시도
    var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, ...);

    if (result.Succeeded)
    {
        // 3a. 성공! 이미 가입하고 연동된 사용자. 로그인 처리 후 returnUrl로 리디렉션
        return LocalRedirect(returnUrl);
    }
    else
    {
        // 3b. 실패. 신규 사용자이거나, 이메일은 같지만 연동되지 않은 사용자.
        return await HandleUserCreationOrLinking(info, returnUrl);
    }
}

HandleUserCreationOrLinking 메서드는 다음 두 가지 시나리오를 처리

  • 시나리오 A: 이메일은 존재하지만 소셜 연동이 안 된 경우 (LinkExistingUser)
    • Google에서 받은 이메일로 우리 DB에서 사용자를 찾습니다. (_userManager.FindByEmailAsync)
    • 기존 사용자가 있다면, 해당 계정에 이 Google 로그인 정보를 추가로 연결합니다. (_userManager.AddLoginAsync)
    • 연결 후, 사용자를 로그인시키고 returnUrl로 리디렉션합니다.
  • 시나리오 B: 완전히 새로운 사용자인 경우 (CreateNewUserAsync)
    • Google에서 받은 이메일과 이름으로 새로운 ApplicationUser 객체를 만듭니다. 이때 EmailConfirmed는 true로 설정합니다. (소셜 로그인은 이미 이메일이 인증된 것으로 간주)
    • _userManager.CreateAsync(user)로 DB에 새 사용자를 저장합니다.
    • 새로 생성된 사용자 계정에 Google 로그인 정보를 연결합니다. (_userManager.AddLoginAsync)
    • 새 사용자를 로그인시키고 returnUrl로 리디렉션합니다.

5️⃣ 완료

로그인 또는 회원가입 및 연동이 모두 성공적으로 끝나면, 백엔드는 최종적으로 LocalRedirect(returnUrl)을 통해 사용자를 맨 처음 Login.razor에서 지정했던 주소(/)로 리디렉션합니다.

이제 사용자는 로그인된 상태로 메인 페이지를 보게 됩니다.

6️⃣ AuthController.cs

csharp
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.IdentityModel.Tokens;
using Neco.BaseTemplate.Shared.Data;
using Neco.BaseTemplete.Data;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;

namespace Neco.BaseTemplete.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class AuthController : ControllerBase
    {
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly IUserStore<ApplicationUser> _userStore;
        private readonly IEmailSender<ApplicationUser> _emailSender;
        private readonly ILogger<AuthController> _logger;

        public AuthController(
            UserManager<ApplicationUser> userManager,
            SignInManager<ApplicationUser> signInManager,
            IUserStore<ApplicationUser> userStore,
            IEmailSender<ApplicationUser> emailSender,
            ILogger<AuthController> logger)
        {
            _userManager = userManager;
            _signInManager = signInManager;
            _userStore = userStore;
            _emailSender = emailSender;
            _logger = logger;
        }

        #region Basic Authentication

        /// <summary>
        /// Handles user login.
        /// </summary>
        /// <param name="model">Login request DTO (email, password, RememberMe option)</param>
        /// <returns>The login result</returns>
        [HttpPost("login")]
        public async Task<IActionResult> Login([FromBody] LoginUserRequestDTO model)
        {
            try
            {
                var result = await _signInManager.PasswordSignInAsync(
                    model.Email,
                    model.Password,
                    model.RememberMe,
                    lockoutOnFailure: true);

                if (result.Succeeded)
                {
                    _logger.LogInformation("User logged in: {Email}", model.Email);
                    return Ok(new { Success = true });
                }

                if (result.RequiresTwoFactor)
                {
                    return Ok(new { RequiresTwoFactor = true });
                }

                if (result.IsLockedOut)
                {
                    _logger.LogWarning("User account locked out: {Email}", model.Email);
                    return BadRequest("Account locked out");
                }

                return BadRequest("Invalid login attempt");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Login error for {Email}", model.Email);
                return StatusCode(500, "An error occurred");
            }
        }

        /// <summary>
        /// Handles new user registration.
        /// </summary>
        /// <param name="model">Registration request DTO</param>
        /// <returns>The registration result</returns>
        [HttpPost("register")]
        public async Task<IActionResult> Register([FromBody] CreateUserRequestDTO model)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(new RegisterUserResponseDTO
                {
                    Status = RegisterUserResponseDTO.ResponseStatus.Fail_RequestInvalid,
                    Errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage)
                });
            }

            var user = CreateUser();
            await _userStore.SetUserNameAsync(user, model.Email, CancellationToken.None);
            var emailStore = GetEmailStore();
            await emailStore.SetEmailAsync(user, model.Email, CancellationToken.None);

            var result = await _userManager.CreateAsync(user, model.Password);

            if (!result.Succeeded)
            {
                _logger.LogInformation("User registration failed: {Email}", model.Email);

                // Handle duplicate email error
                if (result.Errors.Any(e => e.Code == "DuplicateUserName"))
                {
                    return BadRequest(new RegisterUserResponseDTO
                    {
                        Status = RegisterUserResponseDTO.ResponseStatus.Fail_DuplicateEmail,
                    });
                }

                // Handle general Identity errors
                return BadRequest(new RegisterUserResponseDTO
                {
                    Status = RegisterUserResponseDTO.ResponseStatus.Fail_General,
                    Errors = result.Errors.Select(e => e.Description)
                });
            }

            _logger.LogInformation("User created a new account with a password.");

            // Send email confirmation link
            try
            {
                var userId = await _userManager.GetUserIdAsync(user);
                var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));

                // NOTE: For security, it's recommended to configure a fixed base URL
                // from your application's settings rather than relying on the request host.
                var baseUrl = model.BaseUrl ?? $"{Request.Scheme}://{Request.Host.Value}";
                var callbackUrl = $"{baseUrl}/Account/ConfirmEmail?userId={userId}&code={code}";

                await _emailSender.SendConfirmationLinkAsync(user, model.Email, HtmlEncoder.Default.Encode(callbackUrl));
            }
            catch (Exception)
            {
                // Account created successfully but email sending failed.
                return Ok(new RegisterUserResponseDTO
                {
                    Status = RegisterUserResponseDTO.ResponseStatus.Success_EmailWarning,
                    Message = "The account was created, but the verification email sending failed."
                });
            }

            return Ok(new RegisterUserResponseDTO
            {
                Status = RegisterUserResponseDTO.ResponseStatus.Success_NeedConfirmation
            });
        }

        /// <summary>
        /// Resends the email confirmation link.
        /// </summary>
        /// <param name="model">Email confirmation request DTO</param>
        /// <returns>The result of the process</returns>
        [HttpPost("emailConfirm")]
        public async Task<IActionResult> ResendEmailConfirm([FromBody] RequestEmailConfirmDTO model)
        {
            if (!ModelState.IsValid)
            {
                // Always return a success response for security (to avoid user enumeration).
                return Ok(new ResponseEmailConfirmDTO
                {
                    Status = ResponseEmailConfirmDTO.ResponseStatus.Success,
                    Message = "The confirmation email has been sent successfully."
                });
            }

            try
            {
                var user = await _userManager.FindByEmailAsync(model.Email);

                // Even if the user doesn't exist or is already confirmed, return a success response for security.
                if (user == null || user.EmailConfirmed)
                {
                    return Ok(new ResponseEmailConfirmDTO
                    {
                        Status = ResponseEmailConfirmDTO.ResponseStatus.Success,
                        Message = "The confirmation email has been sent successfully."
                    });
                }

                // Generate and send the email confirmation link
                var userId = await _userManager.GetUserIdAsync(user);
                var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));

                // NOTE: It is recommended to use a pre-configured base URL.
                var baseUrl = model.BaseUrl ?? $"{Request.Scheme}://{Request.Host.Value}";
                var callbackUrl = $"{baseUrl}/Account/ConfirmEmail?userId={userId}&code={code}";

                await _emailSender.SendConfirmationLinkAsync(user, model.Email, HtmlEncoder.Default.Encode(callbackUrl));

                return Ok(new ResponseEmailConfirmDTO
                {
                    Status = ResponseEmailConfirmDTO.ResponseStatus.Success,
                    Message = "The confirmation email has been sent successfully."
                });
            }
            catch (Exception)
            {
                // Mail server or other exception occurred.
                return BadRequest(new ResponseEmailConfirmDTO
                {
                    Status = ResponseEmailConfirmDTO.ResponseStatus.Fail,
                    Message = $"An error occurred while processing your request. (Server Error)"
                });
            }
        }

        /// <summary>
        /// Handles password reset requests.
        /// </summary>
        /// <param name="model">Password reset request DTO</param>
        /// <returns>The result of the process</returns>
        [HttpPost("resetPassword")]
        public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequestDTO model)
        {
            var user = await _userManager.FindByEmailAsync(model.Email);
            if (user == null)
            {
                return BadRequest("User not found");
            }

            var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
            var callbackUrl = $"{model.BaseUrl}/Account/ResetPassword?email={model.Email}&token={resetToken}";

            await _emailSender.SendPasswordResetLinkAsync(user, model.Email, callbackUrl);

            return Ok("Password reset link sent");
        }

        /// <summary>
        /// Handles user logout.
        /// </summary>
        /// <returns>The logout result</returns>
        [HttpPost("logout")]
        public async Task<IActionResult> Logout()
        {
            await _signInManager.SignOutAsync();
            return Ok("Logged out successfully");
        }

        /// <summary>
        /// Handles the generation of a JWT token.
        /// </summary>
        /// <param name="model">Login request DTO (email, password)</param>
        /// <returns>Token information</returns>
        [HttpPost("token")]
        public async Task<IActionResult> GenerateToken([FromBody] LoginUserRequestDTO model)
        {
            var user = await _userManager.FindByEmailAsync(model.Email);
            if (user == null || !await _userManager.CheckPasswordAsync(user, model.Password))
            {
                return Unauthorized("Invalid credentials");
            }

            var claims = new[]
            {
                new Claim(ClaimTypes.Name, user.UserName),
                new Claim(ClaimTypes.Email, user.Email)
            };

            // WARNING: Do not hard-code the key in production.
            // Move this to a secure configuration file (e.g., appsettings.json)
            // and use a strong, randomly generated key.
            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("A_Very_Strong_And_Secret_Key_For_JWT_Auth"));
            var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

            var token = new JwtSecurityToken(
                issuer: "yourdomain.com",
                audience: "yourdomain.com",
                claims: claims,
                expires: DateTime.Now.AddMinutes(30),
                signingCredentials: creds);

            return Ok(new { token = new JwtSecurityTokenHandler().WriteToken(token) });
        }

        #endregion

        #region External Authentication (OAuth)

        /// <summary>
        /// Initiates a challenge request to an external authentication provider (e.g., Google).
        /// </summary>
        /// <param name="provider">The name of the authentication provider (e.g., "Google")</param>
        /// <param name="returnUrl">The URL to redirect to after successful authentication</param>
        /// <returns>A challenge result to the external provider</returns>
        [HttpGet("Challenge/{provider}")]
        public IActionResult Challenge(string provider, string? returnUrl = null)
        {
            _logger.LogInformation("Starting {Provider} login", provider);

            if (string.IsNullOrWhiteSpace(provider))
            {
                return BadRequest(new { error = "Provider not specified." });
            }

            // Normalize and validate returnUrl
            returnUrl = NormalizeReturnUrl(returnUrl);

            // Set the callback URL
            var redirectUrl = Url.Action("Callback", "Auth", new { returnUrl }, Request.Scheme);

            // Configure authentication properties
            var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);

            return Challenge(properties, provider);
        }

        /// <summary>
        /// Handles the callback from an external authentication provider.
        /// </summary>
        /// <param name="returnUrl">The URL to redirect to after successful authentication</param>
        /// <param name="remoteError">Error message from the external provider</param>
        /// <returns>An appropriate response based on the authentication result</returns>
        [HttpGet("externalLogin")]
        public async Task<IActionResult> Callback(string? returnUrl = null, string? remoteError = null)
        {
            try
            {
                returnUrl = NormalizeReturnUrl(returnUrl);

                if (!string.IsNullOrEmpty(remoteError))
                {
                    _logger.LogError("External provider error: {RemoteError}", remoteError);
                    return Redirect($"/login?error={Uri.EscapeDataString(remoteError)}");
                }

                var info = await _signInManager.GetExternalLoginInfoAsync();
                if (info == null)
                {
                    _logger.LogError("Could not retrieve external login info");
                    return Redirect("/login?error=external_login_failed");
                }

                // Attempt to sign in an existing user
                var result = await _signInManager.ExternalLoginSignInAsync(
                    info.LoginProvider,
                    info.ProviderKey,
                    isPersistent: false,
                    bypassTwoFactor: true);

                if (result.Succeeded)
                {
                    var user = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
                    _logger.LogInformation("User {Email} successfully logged in", user?.Email);
                    return LocalRedirect(returnUrl);
                }

                if (result.IsLockedOut)
                {
                    _logger.LogWarning("Attempt to log in with a locked-out account");
                    return Redirect("/login?error=locked_out");
                }

                // New user or linking an existing user
                return await HandleUserCreationOrLinking(info, returnUrl);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error occurred while processing external login");
                return Redirect("/login?error=server_error");
            }
        }

        #endregion

        #region Helper Methods

        /// <summary>
        /// Normalizes and validates the returnUrl.
        /// </summary>
        private string NormalizeReturnUrl(string? returnUrl)
        {
            if (string.IsNullOrWhiteSpace(returnUrl))
                return "/";

            if (Uri.TryCreate(returnUrl, UriKind.Absolute, out var uri))
                returnUrl = uri.PathAndQuery;

            return Url.IsLocalUrl(returnUrl) ? returnUrl : "/";
        }

        /// <summary>
        /// Handles new user creation or linking an existing user to an external login.
        /// </summary>
        private async Task<IActionResult> HandleUserCreationOrLinking(ExternalLoginInfo info, string returnUrl)
        {
            var email = info.Principal.FindFirstValue(ClaimTypes.Email);
            if (string.IsNullOrEmpty(email))
            {
                _logger.LogError("Missing email information from external provider {Provider}", info.LoginProvider);
                return Redirect("/login?error=email_required");
            }

            var existingUser = await _userManager.FindByEmailAsync(email);
            return existingUser != null
                ? await LinkExistingUser(existingUser, info, returnUrl)
                : await CreateNewUserAsync(info, returnUrl);
        }

        /// <summary>
        /// Links external login information to an existing user.
        /// </summary>
        private async Task<IActionResult> LinkExistingUser(ApplicationUser user, ExternalLoginInfo info, string returnUrl)
        {
            var addLoginResult = await _userManager.AddLoginAsync(user, info);
            if (addLoginResult.Succeeded)
            {
                _logger.LogInformation("Successfully linked {Provider} login to existing user {Email}", info.LoginProvider, user.Email);
                await _signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider);
                return LocalRedirect(returnUrl);
            }

            _logger.LogError("Failed to link external login to existing user: {Errors}",
                string.Join(", ", addLoginResult.Errors.Select(e => e.Description)));
            return Redirect("/login?error=link_failed");
        }

        /// <summary>
        /// Creates a new user based on external login information.
        /// </summary>
        private async Task<IActionResult> CreateNewUserAsync(ExternalLoginInfo info, string returnUrl)
        {
            var email = info.Principal.FindFirstValue(ClaimTypes.Email);
            if (string.IsNullOrEmpty(email))
            {
                _logger.LogError("Missing email information from external provider");
                return BadRequest(new { error = "Email information was not provided." });
            }

            var userName = email.Split('@')[0];
            var user = new ApplicationUser
            {
                UserName = email,
                Email = email,
                NickName = userName,
                EmailConfirmed = true // Email is already confirmed via external provider
            };

            var createResult = await _userManager.CreateAsync(user);
            if (!createResult.Succeeded)
            {
                _logger.LogError("User creation failed: {Errors}",
                    string.Join(", ", createResult.Errors.Select(e => e.Description)));

                // Attempt to create an alternative user name on a naming conflict
                if (createResult.Errors.Any(e => e.Code == "DuplicateUserName"))
                {
                    user.UserName = $"{userName}_{Guid.NewGuid().ToString("N").Substring(0, 4)}";
                    createResult = await _userManager.CreateAsync(user);

                    if (!createResult.Succeeded)
                    {
                        return BadRequest(new
                        {
                            error = "Account creation failed",
                            details = createResult.Errors.Select(e => e.Description)
                        });
                    }
                }
                else
                {
                    return BadRequest(new
                    {
                        error = "Account creation failed",
                        details = createResult.Errors.Select(e => e.Description)
                    });
                }
            }

            // Link external login information
            var addLoginResult = await _userManager.AddLoginAsync(user, info);
            if (!addLoginResult.Succeeded)
            {
                _logger.LogError("Failed to add login information: {Errors}",
                    string.Join(", ", addLoginResult.Errors.Select(e => e.Description)));

                // Rollback: Delete the created user
                await _userManager.DeleteAsync(user);

                return BadRequest(new
                {
                    error = "Failed to link external login information",
                    details = addLoginResult.Errors.Select(e => e.Description)
                });
            }

            _logger.LogInformation("New user created and logged in successfully: {Email}", email);
            await _signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider);

            return LocalRedirect(returnUrl);
        }

        private ApplicationUser CreateUser()
        {
            try
            {
                return Activator.CreateInstance<ApplicationUser>();
            }
            catch
            {
                throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
                    $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor.");
            }
        }

        private IUserEmailStore<ApplicationUser> GetEmailStore()
        {
            if (!_userManager.SupportsUserEmail)
            {
                throw new NotSupportedException("The default UI requires a user store with email support.");
            }
            return (IUserEmailStore<ApplicationUser>)_userStore;
        }

        #endregion

        #region Profile Management

        /// <summary>
        /// Handles user profile updates.
        /// </summary>
        /// <param name="model">Update request DTO</param>
        /// <returns>The result of the process</returns>
        [HttpPut("updateProfile")]
        public async Task<IActionResult> UpdateProfile([FromBody] UpdateUserProfileDTO model)
        {
            var user = await _userManager.FindByIdAsync(model.Email);
            if (user == null)
            {
                return NotFound("User not found");
            }

            user.NickName = model.Nickname;
            user.PhoneNumber = model.PhoneNumber;

            var result = await _userManager.UpdateAsync(user);
            if (!result.Succeeded)
            {
                return BadRequest(result.Errors.Select(e => e.Description));
            }

            return Ok("Profile updated successfully");
        }

        /// <summary>
        /// Handles user deletion.
        /// </summary>
        /// <param name="userId">The ID of the user to delete</param>
        /// <returns>The result of the process</returns>
        [HttpDelete("deleteUser/{userId}")]
        public async Task<IActionResult> DeleteUser(string userId)
        {
            var user = await _userManager.FindByIdAsync(userId);
            if (user == null)
            {
                return NotFound("User not found");
            }

            var result = await _userManager.DeleteAsync(user);
            if (!result.Succeeded)
            {
                return BadRequest(result.Errors.Select(e => e.Description));
            }

            return Ok("User deleted successfully");
        }

        #endregion
    }
}
csharp
private void LoginWithGoogle()
{
    try
    {
        var returnUrl = "/";
        var googleLoginUrl = $"/api/auth/Challenge/Google?returnUrl={Uri.EscapeDataString(returnUrl)}";

        // Redirect in the current window instead of a popup
        // This is a placeholder for a client-side navigation method, e.g., in Blazor
        // Navigation.NavigateTo(googleLoginUrl, forceLoad: true);
    }
    catch (Exception ex)
    {
        // Error handling
        Console.WriteLine($"Google login error: {ex.Message}");
    }
}