Files
pos-system/services/iam-service-net/src/IamService.API/Controllers/AuthController.cs
Ho Ngoc Hai c621afbb74 feat(api): Enhance authentication and user management endpoints
- Updated API documentation to include new user management features such as password change and logout functionalities.
- Added detailed descriptions and examples for OAuth2 token endpoint, supporting password, refresh token, and client credentials grants.
- Introduced new endpoints for user management, including retrieving, updating, and deleting users.
- Enhanced Swagger annotations for better clarity and usability of the API documentation.
- Implemented response models for password change and logout operations to standardize API responses.
2026-01-12 16:25:54 +07:00

438 lines
18 KiB
C#

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;
/// <summary>
/// EN: Authentication controller with OpenIddict OAuth2/OIDC endpoints.
/// VI: Controller xác thực với OpenIddict OAuth2/OIDC endpoints.
/// </summary>
[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<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ILogger<AuthController> _logger;
public AuthController(
IMediator mediator,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ILogger<AuthController> logger)
{
_mediator = mediator;
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
}
/// <summary>
/// EN: Register a new user.
/// VI: Đăng ký user mới.
/// </summary>
/// <param name="command">User registration data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Registered user information</returns>
[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<RegisterUserCommandResult>))]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid registration data")]
[SwaggerResponse(StatusCodes.Status409Conflict, "User with this email already exists")]
[ProducesResponseType(typeof(ApiResponse<RegisterUserCommandResult>), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> 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<RegisterUserCommandResult>.Ok(result));
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// **Password Grant (Login):**
/// ```
/// POST /connect/token
/// Content-Type: application/x-www-form-urlencoded
///
/// grant_type=password&amp;username=user@example.com&amp;password=YourPassword&amp;scope=openid profile email roles api
/// ```
///
/// **Refresh Token Grant:**
/// ```
/// POST /connect/token
/// Content-Type: application/x-www-form-urlencoded
///
/// grant_type=refresh_token&amp;refresh_token=YOUR_REFRESH_TOKEN
/// ```
///
/// **Client Credentials Grant:**
/// ```
/// POST /connect/token
/// Content-Type: application/x-www-form-urlencoded
///
/// grant_type=client_credentials&amp;client_id=YOUR_CLIENT_ID&amp;client_secret=YOUR_CLIENT_SECRET&amp;scope=api
/// ```
/// </remarks>
/// <returns>OAuth2 token response with access_token, refresh_token, expires_in</returns>
[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<IActionResult> 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<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.UnsupportedGrantType,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The specified grant type is not supported."
}));
}
private async Task<IActionResult> 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<string, string?>
{
[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<string, string?>
{
[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<string, string?>
{
[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<IActionResult> 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<string, string?>
{
[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<string, string?>
{
[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<IActionResult> 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<IActionResult>(
SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme));
}
/// <summary>
/// EN: Change user password.
/// VI: Đổi mật khẩu user.
/// </summary>
/// <param name="request">Change password request data</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of password change operation</returns>
[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<IActionResult> 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 });
}
/// <summary>
/// EN: Logout user and revoke tokens.
/// VI: Logout user và thu hồi tokens.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of logout operation</returns>
[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<IActionResult> 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<string> 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;
}
}
}
/// <summary>
/// EN: Request body for changing password.
/// VI: Request body để đổi mật khẩu.
/// </summary>
public class ChangePasswordRequest
{
/// <summary>
/// EN: Current password.
/// VI: Mật khẩu hiện tại.
/// </summary>
/// <example>OldPassword123!</example>
public string CurrentPassword { get; set; } = string.Empty;
/// <summary>
/// EN: New password.
/// VI: Mật khẩu mới.
/// </summary>
/// <example>NewPassword456!</example>
public string NewPassword { get; set; } = string.Empty;
}
/// <summary>
/// EN: Response for change password operation.
/// VI: Response cho thao tác đổi mật khẩu.
/// </summary>
public class ChangePasswordResponse
{
/// <summary>
/// EN: Whether the operation was successful.
/// VI: Thao tác có thành công không.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// EN: Result message.
/// VI: Thông điệp kết quả.
/// </summary>
public string Message { get; set; } = string.Empty;
}
/// <summary>
/// EN: Response for logout operation.
/// VI: Response cho thao tác logout.
/// </summary>
public class LogoutResponse
{
/// <summary>
/// EN: Whether the operation was successful.
/// VI: Thao tác có thành công không.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// EN: Result message.
/// VI: Thông điệp kết quả.
/// </summary>
public string Message { get; set; } = string.Empty;
}