using System.Security.Claims; using Asp.Versioning; using MediatR; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; using OpenIddict.Validation.AspNetCore; using Swashbuckle.AspNetCore.Annotations; using IamService.API.Application.Commands.Auth; using IamService.API.Application.Common; using IamService.Domain.AggregatesModel.UserAggregate; using static OpenIddict.Abstractions.OpenIddictConstants; namespace IamService.API.Controllers; /// /// EN: Authentication controller with OpenIddict OAuth2/OIDC endpoints. /// VI: Controller xác thực với OpenIddict OAuth2/OIDC endpoints. /// [ApiController] [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/auth")] [SwaggerTag("Authentication endpoints - OAuth2/OIDC")] public class AuthController : ControllerBase { private readonly IMediator _mediator; private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly ILogger _logger; public AuthController( IMediator mediator, UserManager userManager, SignInManager signInManager, ILogger logger) { _mediator = mediator; _userManager = userManager; _signInManager = signInManager; _logger = logger; } /// /// EN: Register a new user. /// VI: Đăng ký user mới. /// /// User registration data /// Cancellation token /// Registered user information [HttpPost("register")] [SwaggerOperation( Summary = "Register a new user", Description = "Creates a new user account with email and password.", OperationId = "RegisterUser")] [SwaggerResponse(StatusCodes.Status201Created, "User successfully registered", typeof(ApiResponse))] [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid registration data")] [SwaggerResponse(StatusCodes.Status409Conflict, "User with this email already exists")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task Register( [FromBody, SwaggerRequestBody("User registration details", Required = true)] RegisterUserCommand command, CancellationToken cancellationToken) { var result = await _mediator.Send(command, cancellationToken); return CreatedAtAction(nameof(Register), new { id = result.UserId }, ApiResponse.Ok(result)); } /// /// EN: OAuth2 Token endpoint - supports password, refresh_token, and client_credentials grants. /// VI: OAuth2 Token endpoint - hỗ trợ password, refresh_token, và client_credentials grants. /// /// /// **Password Grant (Login):** /// ``` /// POST /connect/token /// Content-Type: application/x-www-form-urlencoded /// /// grant_type=password&username=user@example.com&password=YourPassword&scope=openid profile email roles api /// ``` /// /// **Refresh Token Grant:** /// ``` /// POST /connect/token /// Content-Type: application/x-www-form-urlencoded /// /// grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN /// ``` /// /// **Client Credentials Grant:** /// ``` /// POST /connect/token /// Content-Type: application/x-www-form-urlencoded /// /// grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&scope=api /// ``` /// /// OAuth2 token response with access_token, refresh_token, expires_in [HttpPost("~/connect/token")] [Consumes("application/x-www-form-urlencoded")] [Produces("application/json")] [SwaggerOperation( Summary = "OAuth2 Token Endpoint", Description = "Exchanges credentials for access tokens. Supports password, refresh_token, and client_credentials grant types.", OperationId = "GetToken", Tags = new[] { "Authentication" })] [SwaggerResponse(StatusCodes.Status200OK, "Token issued successfully", typeof(TokenResponse))] [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request (missing parameters)")] [SwaggerResponse(StatusCodes.Status401Unauthorized, "Invalid credentials or token")] [SwaggerResponse(StatusCodes.Status403Forbidden, "Account locked or unsupported grant type")] [ProducesResponseType(typeof(TokenResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task Exchange() { var request = HttpContext.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); if (request.IsPasswordGrantType()) { return await HandlePasswordGrantAsync(request); } if (request.IsRefreshTokenGrantType()) { return await HandleRefreshTokenGrantAsync(); } if (request.IsClientCredentialsGrantType()) { return await HandleClientCredentialsGrantAsync(request); } _logger.LogWarning("Unsupported grant type: {GrantType}", request.GrantType); return Forbid( authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, properties: new AuthenticationProperties(new Dictionary { [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.UnsupportedGrantType, [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The specified grant type is not supported." })); } private async Task HandlePasswordGrantAsync(OpenIddictRequest request) { var user = await _userManager.FindByEmailAsync(request.Username!); if (user == null) { _logger.LogWarning("Login failed: user not found for {Email}", request.Username); return Forbid( authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, properties: new AuthenticationProperties(new Dictionary { [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Invalid email or password." })); } // EN: Check password // VI: Kiểm tra password var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password!, lockoutOnFailure: true); if (result.IsLockedOut) { _logger.LogWarning("Login failed: user {UserId} is locked out", user.Id); return Forbid( authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, properties: new AuthenticationProperties(new Dictionary { [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Account is locked. Please try again later." })); } if (!result.Succeeded) { _logger.LogWarning("Login failed: invalid password for user {UserId}", user.Id); return Forbid( authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, properties: new AuthenticationProperties(new Dictionary { [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Invalid email or password." })); } // EN: Record login and create claims // VI: Ghi nhận login và tạo claims user.RecordLogin(); var identity = new ClaimsIdentity( authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, nameType: Claims.Name, roleType: Claims.Role); // EN: Add claims // VI: Thêm claims identity.SetClaim(Claims.Subject, user.Id.ToString()) .SetClaim(Claims.Email, user.Email) .SetClaim(Claims.Name, user.FullName) .SetClaim("first_name", user.FirstName) .SetClaim("last_name", user.LastName); // EN: Add roles to claims // VI: Thêm roles vào claims var roles = await _userManager.GetRolesAsync(user); identity.SetClaims(Claims.Role, [.. roles]); // EN: Set destinations for claims // VI: Set destinations cho claims identity.SetDestinations(GetDestinations); var principal = new ClaimsPrincipal(identity); // EN: Set scopes // VI: Set scopes principal.SetScopes(request.GetScopes()); _logger.LogInformation("User {UserId} logged in successfully", user.Id); return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } private async Task HandleRefreshTokenGrantAsync() { var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); var userId = result.Principal?.GetClaim(Claims.Subject); if (string.IsNullOrEmpty(userId)) { return Forbid( authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, properties: new AuthenticationProperties(new Dictionary { [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The refresh token is no longer valid." })); } var user = await _userManager.FindByIdAsync(userId); if (user == null) { return Forbid( authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, properties: new AuthenticationProperties(new Dictionary { [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user no longer exists." })); } // EN: Recreate principal with updated claims // VI: Tạo lại principal với claims đã cập nhật var identity = new ClaimsIdentity(result.Principal!.Claims, authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, nameType: Claims.Name, roleType: Claims.Role); identity.SetDestinations(GetDestinations); return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } private Task HandleClientCredentialsGrantAsync(OpenIddictRequest request) { var identity = new ClaimsIdentity( authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, nameType: Claims.Name, roleType: Claims.Role); identity.SetClaim(Claims.Subject, request.ClientId); identity.SetDestinations(GetDestinations); var principal = new ClaimsPrincipal(identity); principal.SetScopes(request.GetScopes()); return Task.FromResult( SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)); } /// /// EN: Change user password. /// VI: Đổi mật khẩu user. /// /// Change password request data /// Cancellation token /// Result of password change operation [HttpPost("change-password")] [Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] [SwaggerOperation( Summary = "Change password", Description = "Changes the password for the currently authenticated user. Requires current password verification.", OperationId = "ChangePassword")] [SwaggerResponse(StatusCodes.Status200OK, "Password changed successfully", typeof(ChangePasswordResponse))] [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request (current password incorrect)")] [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] [ProducesResponseType(typeof(ChangePasswordResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task ChangePassword( [FromBody, SwaggerRequestBody("Password change data", Required = true)] ChangePasswordRequest request, CancellationToken cancellationToken) { var userIdClaim = User.FindFirst("sub")?.Value; if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) { return Unauthorized(); } var command = new ChangePasswordCommand(userId, request.CurrentPassword, request.NewPassword); var result = await _mediator.Send(command, cancellationToken); if (!result.Success) { return BadRequest(new ChangePasswordResponse { Success = false, Message = result.Message }); } return Ok(new ChangePasswordResponse { Success = true, Message = result.Message }); } /// /// EN: Logout user and revoke tokens. /// VI: Logout user và thu hồi tokens. /// /// Cancellation token /// Result of logout operation [HttpPost("logout")] [Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] [SwaggerOperation( Summary = "Logout", Description = "Logs out the current user and revokes all associated tokens.", OperationId = "Logout")] [SwaggerResponse(StatusCodes.Status200OK, "User logged out successfully", typeof(LogoutResponse))] [SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")] [ProducesResponseType(typeof(LogoutResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task Logout(CancellationToken cancellationToken) { var userIdClaim = User.FindFirst("sub")?.Value; if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) { return Unauthorized(); } var command = new LogoutCommand(userId); var result = await _mediator.Send(command, cancellationToken); return Ok(new LogoutResponse { Success = result.Success, Message = result.Message }); } private static IEnumerable GetDestinations(Claim claim) { switch (claim.Type) { case Claims.Name or Claims.Email: yield return Destinations.AccessToken; if (claim.Subject?.HasScope(Scopes.Profile) == true) yield return Destinations.IdentityToken; yield break; case Claims.Role: yield return Destinations.AccessToken; if (claim.Subject?.HasScope(Scopes.Roles) == true) yield return Destinations.IdentityToken; yield break; default: yield return Destinations.AccessToken; yield break; } } } /// /// EN: Request body for changing password. /// VI: Request body để đổi mật khẩu. /// public class ChangePasswordRequest { /// /// EN: Current password. /// VI: Mật khẩu hiện tại. /// /// OldPassword123! public string CurrentPassword { get; set; } = string.Empty; /// /// EN: New password. /// VI: Mật khẩu mới. /// /// NewPassword456! public string NewPassword { get; set; } = string.Empty; } /// /// EN: Response for change password operation. /// VI: Response cho thao tác đổi mật khẩu. /// public class ChangePasswordResponse { /// /// EN: Whether the operation was successful. /// VI: Thao tác có thành công không. /// public bool Success { get; set; } /// /// EN: Result message. /// VI: Thông điệp kết quả. /// public string Message { get; set; } = string.Empty; } /// /// EN: Response for logout operation. /// VI: Response cho thao tác logout. /// public class LogoutResponse { /// /// EN: Whether the operation was successful. /// VI: Thao tác có thành công không. /// public bool Success { get; set; } /// /// EN: Result message. /// VI: Thông điệp kết quả. /// public string Message { get; set; } = string.Empty; }