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

1️⃣ 사용자가 “Google로 로그인” 버튼을 클릭
Navigation.NavigateTo(..., forceLoad: true)
Blazor의 내부 라우팅이 아닌, 브라우저가 직접 해당 URL(api/auth/Challenge/Google...
)로 GET 요청보낸다.
private void LoginWithGoogle() { var returnUrl = "/"; // 로그인 성공 후 돌아올 주소 var googleLoginUrl = $"/api/auth/Challenge/Google?returnUrl={Uri.EscapeDataString(returnUrl)}"; // 이 주소로 브라우저가 페이지를 새로고침하며 이동 Navigation.NavigateTo(googleLoginUrl, forceLoad: true); }
2️⃣ 백엔드의 인증 시작 (AuthController – Challenge)
// 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
) 메서드가 실행
// 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
로 리디렉션합니다.
- Google에서 받은 이메일로 우리 DB에서 사용자를 찾습니다. (
- 시나리오 B: 완전히 새로운 사용자인 경우 (
CreateNewUserAsync
)- Google에서 받은 이메일과 이름으로 새로운
ApplicationUser
객체를 만듭니다. 이때EmailConfirmed
는true
로 설정합니다. (소셜 로그인은 이미 이메일이 인증된 것으로 간주) _userManager.CreateAsync(user)
로 DB에 새 사용자를 저장합니다.- 새로 생성된 사용자 계정에 Google 로그인 정보를 연결합니다. (
_userManager.AddLoginAsync
) - 새 사용자를 로그인시키고
returnUrl
로 리디렉션합니다.
- Google에서 받은 이메일과 이름으로 새로운
5️⃣ 완료
로그인 또는 회원가입 및 연동이 모두 성공적으로 끝나면, 백엔드는 최종적으로 LocalRedirect(returnUrl)
을 통해 사용자를 맨 처음 Login.razor
에서 지정했던 주소(/
)로 리디렉션합니다.
이제 사용자는 로그인된 상태로 메인 페이지를 보게 됩니다.
6️⃣ AuthController.cs
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 } }
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}"); } }