- Replaced OpenIddict references with Duende IdentityServer in the project, including updates to the API project and infrastructure. - Refactored authentication and authorization logic in AuthController, LogoutCommandHandler, and related services to align with Duende IdentityServer's structure. - Updated dependency injection configuration to register Duende IdentityServer components and JWT Bearer authentication. - Enhanced functional tests to accommodate changes in authentication flow and ensure compatibility with the new identity server. - Removed obsolete OpenIddict components and related code to streamline the project.
342 lines
13 KiB
C#
342 lines
13 KiB
C#
// EN: Authentication controller with Duende IdentityServer integration.
|
|
// VI: Controller xác thực với Duende IdentityServer integration.
|
|
|
|
using System.Security.Claims;
|
|
using Asp.Versioning;
|
|
using Duende.IdentityServer;
|
|
using Duende.IdentityServer.Events;
|
|
using Duende.IdentityServer.Services;
|
|
using MediatR;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Swashbuckle.AspNetCore.Annotations;
|
|
using IamService.API.Application.Commands.Auth;
|
|
using IamService.API.Application.Common;
|
|
using IamService.Domain.AggregatesModel.UserAggregate;
|
|
|
|
namespace IamService.API.Controllers;
|
|
|
|
/// <summary>
|
|
/// EN: Authentication controller with Duende IdentityServer integration.
|
|
/// VI: Controller xác thực với Duende IdentityServer integration.
|
|
/// </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 IIdentityServerInteractionService _interaction;
|
|
private readonly IEventService _events;
|
|
private readonly ILogger<AuthController> _logger;
|
|
|
|
public AuthController(
|
|
IMediator mediator,
|
|
UserManager<ApplicationUser> userManager,
|
|
SignInManager<ApplicationUser> signInManager,
|
|
IIdentityServerInteractionService interaction,
|
|
IEventService events,
|
|
ILogger<AuthController> logger)
|
|
{
|
|
_mediator = mediator;
|
|
_userManager = userManager;
|
|
_signInManager = signInManager;
|
|
_interaction = interaction;
|
|
_events = events;
|
|
_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: Login with email and password (Resource Owner Password Grant).
|
|
/// VI: Đăng nhập với email và password (Resource Owner Password Grant).
|
|
/// </summary>
|
|
/// <param name="request">Login credentials</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Login result with token info</returns>
|
|
[HttpPost("login")]
|
|
[SwaggerOperation(
|
|
Summary = "Login with credentials",
|
|
Description = "Authenticates a user with email and password. For full OAuth2 flow, use /connect/token endpoint.",
|
|
OperationId = "Login")]
|
|
[SwaggerResponse(StatusCodes.Status200OK, "Login successful")]
|
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid credentials")]
|
|
[SwaggerResponse(StatusCodes.Status403Forbidden, "Account locked")]
|
|
[ProducesResponseType(typeof(ApiResponse<LoginResponse>), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
public async Task<IActionResult> Login(
|
|
[FromBody, SwaggerRequestBody("Login credentials", Required = true)] LoginRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var user = await _userManager.FindByEmailAsync(request.Email);
|
|
if (user == null)
|
|
{
|
|
_logger.LogWarning("Login failed: user not found for {Email}", request.Email);
|
|
return BadRequest(ApiResponse<LoginResponse>.Fail("INVALID_CREDENTIALS", "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);
|
|
await _events.RaiseAsync(new UserLoginFailureEvent(user.Email!, "User locked out", clientId: null));
|
|
return StatusCode(StatusCodes.Status403Forbidden,
|
|
ApiResponse<LoginResponse>.Fail("ACCOUNT_LOCKED", "Account is locked. Please try again later."));
|
|
}
|
|
|
|
if (!result.Succeeded)
|
|
{
|
|
_logger.LogWarning("Login failed: invalid password for user {UserId}", user.Id);
|
|
await _events.RaiseAsync(new UserLoginFailureEvent(user.Email!, "Invalid credentials", clientId: null));
|
|
return BadRequest(ApiResponse<LoginResponse>.Fail("INVALID_CREDENTIALS", "Invalid email or password."));
|
|
}
|
|
|
|
// EN: Record login
|
|
// VI: Ghi nhận login
|
|
user.RecordLogin();
|
|
await _userManager.UpdateAsync(user);
|
|
|
|
await _events.RaiseAsync(new UserLoginSuccessEvent(user.Email!, user.Id.ToString(), user.FullName, clientId: null));
|
|
|
|
_logger.LogInformation("User {UserId} logged in successfully", user.Id);
|
|
|
|
// EN: Note: Full token response requires OAuth2 flow via /connect/token
|
|
// VI: Lưu ý: Response token đầy đủ yêu cầu OAuth2 flow qua /connect/token
|
|
return Ok(ApiResponse<LoginResponse>.Ok(new LoginResponse
|
|
{
|
|
Success = true,
|
|
Message = "Login successful. Use /connect/token with grant_type=password for access tokens.",
|
|
UserId = user.Id,
|
|
Email = user.Email!,
|
|
FullName = user.FullName
|
|
}));
|
|
}
|
|
|
|
/// <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 = JwtBearerDefaults.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 ?? User.FindFirst(ClaimTypes.NameIdentifier)?.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 = JwtBearerDefaults.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 ?? User.FindFirst(ClaimTypes.NameIdentifier)?.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);
|
|
|
|
await _signInManager.SignOutAsync();
|
|
|
|
return Ok(new LogoutResponse { Success = result.Success, Message = result.Message });
|
|
}
|
|
}
|
|
|
|
#region Request/Response Models
|
|
|
|
/// <summary>
|
|
/// EN: Login request body.
|
|
/// VI: Request body đăng nhập.
|
|
/// </summary>
|
|
public class LoginRequest
|
|
{
|
|
/// <summary>
|
|
/// EN: User email.
|
|
/// VI: Email người dùng.
|
|
/// </summary>
|
|
/// <example>user@example.com</example>
|
|
public string Email { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// EN: User password.
|
|
/// VI: Mật khẩu người dùng.
|
|
/// </summary>
|
|
/// <example>Password123!</example>
|
|
public string Password { get; set; } = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Login response.
|
|
/// VI: Response đăng nhập.
|
|
/// </summary>
|
|
public class LoginResponse
|
|
{
|
|
/// <summary>
|
|
/// EN: Whether the login was successful.
|
|
/// VI: Đăng nhập 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: User ID.
|
|
/// VI: ID người dùng.
|
|
/// </summary>
|
|
public Guid UserId { get; set; }
|
|
|
|
/// <summary>
|
|
/// EN: User email.
|
|
/// VI: Email người dùng.
|
|
/// </summary>
|
|
public string Email { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// EN: User full name.
|
|
/// VI: Tên đầy đủ người dùng.
|
|
/// </summary>
|
|
public string FullName { get; set; } = string.Empty;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
#endregion
|