🔥 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}");
}
}



