feat(authentication): Migrate from OpenIddict to Duende IdentityServer for OAuth2 support

- 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.
This commit is contained in:
Ho Ngoc Hai
2026-01-12 20:29:15 +07:00
parent eb5cb28d9f
commit 93165f4549
17 changed files with 402 additions and 804 deletions

View File

@@ -1,27 +1,29 @@
// EN: Handler for LogoutCommand - logs out the user.
// VI: Handler cho LogoutCommand - logout user.
using MediatR;
using Microsoft.AspNetCore.Identity;
using OpenIddict.Abstractions;
using IamService.Domain.AggregatesModel.UserAggregate;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Handler for LogoutCommand - revokes all tokens for the user.
/// VI: Handler cho LogoutCommand - thu hồi tất cả tokens của user.
/// EN: Handler for LogoutCommand - logs out the user.
/// VI: Handler cho LogoutCommand - logout user.
/// </summary>
public class LogoutCommandHandler : IRequestHandler<LogoutCommand, LogoutCommandResult>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly IOpenIddictTokenManager _tokenManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ILogger<LogoutCommandHandler> _logger;
public LogoutCommandHandler(
UserManager<ApplicationUser> userManager,
IOpenIddictTokenManager tokenManager,
SignInManager<ApplicationUser> signInManager,
ILogger<LogoutCommandHandler> logger)
{
_userManager = userManager;
_tokenManager = tokenManager;
_signInManager = signInManager;
_logger = logger;
}
@@ -41,24 +43,17 @@ public class LogoutCommandHandler : IRequestHandler<LogoutCommand, LogoutCommand
try
{
// EN: Revoke all tokens for this user
// VI: Thu hồi tất cả tokens của user này
var tokens = _tokenManager.FindBySubjectAsync(request.UserId.ToString(), cancellationToken);
// EN: Sign out from Identity
// VI: Đăng xuất khỏi Identity
await _signInManager.SignOutAsync();
await foreach (var token in tokens)
{
await _tokenManager.TryRevokeAsync(token, cancellationToken);
}
_logger.LogInformation("All tokens revoked for user {UserId}", request.UserId);
return new LogoutCommandResult(true, "User logged out successfully. All tokens revoked.");
_logger.LogInformation("User {UserId} logged out successfully", request.UserId);
return new LogoutCommandResult(true, "User logged out successfully.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to revoke tokens for user {UserId}", request.UserId);
// EN: Still consider it a logout even if token revocation fails
// VI: Vẫn coi như logout ngay cả khi thu hồi token thất bại
return new LogoutCommandResult(true, "User logged out. Token revocation may be pending.");
_logger.LogError(ex, "Failed to logout user {UserId}", request.UserId);
return new LogoutCommandResult(true, "User logged out. Session cleanup may be pending.");
}
}
}

View File

@@ -1,24 +1,26 @@
// 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;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
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.
/// EN: Authentication controller with Duende IdentityServer integration.
/// VI: Controller xác thực với Duende IdentityServer integration.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
@@ -29,17 +31,23 @@ 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;
}
@@ -69,219 +77,73 @@ public class AuthController : ControllerBase
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.
/// EN: Login with email and password (Resource Owner Password Grant).
/// VI: Đăng nhập với email và password (Resource Owner Password Grant).
/// </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")]
/// <param name="request">Login credentials</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Login result with token info</returns>
[HttpPost("login")]
[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)]
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.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Exchange()
public async Task<IActionResult> Login(
[FromBody, SwaggerRequestBody("Login credentials", Required = true)] LoginRequest request,
CancellationToken cancellationToken)
{
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!);
var user = await _userManager.FindByEmailAsync(request.Email);
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."
}));
_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);
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."
}));
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);
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Invalid email or password."
}));
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 and create claims
// VI: Ghi nhận login và tạo claims
// EN: Record login
// VI: Ghi nhận login
user.RecordLogin();
await _userManager.UpdateAsync(user);
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());
await _events.RaiseAsync(new UserLoginSuccessEvent(user.Email!, user.Id.ToString(), user.FullName, clientId: null));
_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))
// 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
{
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));
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>
@@ -292,7 +154,7 @@ public class AuthController : ControllerBase
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of password change operation</returns>
[HttpPost("change-password")]
[Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
[Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[SwaggerOperation(
Summary = "Change password",
Description = "Changes the password for the currently authenticated user. Requires current password verification.",
@@ -307,7 +169,7 @@ public class AuthController : ControllerBase
[FromBody, SwaggerRequestBody("Password change data", Required = true)] ChangePasswordRequest request,
CancellationToken cancellationToken)
{
var userIdClaim = User.FindFirst("sub")?.Value;
var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
return Unauthorized();
@@ -331,7 +193,7 @@ public class AuthController : ControllerBase
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of logout operation</returns>
[HttpPost("logout")]
[Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
[Microsoft.AspNetCore.Authorization.Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[SwaggerOperation(
Summary = "Logout",
Description = "Logs out the current user and revokes all associated tokens.",
@@ -342,7 +204,7 @@ public class AuthController : ControllerBase
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
{
var userIdClaim = User.FindFirst("sub")?.Value;
var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
return Unauthorized();
@@ -351,30 +213,70 @@ public class AuthController : ControllerBase
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 });
}
}
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;
#region Request/Response Models
case Claims.Role:
yield return Destinations.AccessToken;
if (claim.Subject?.HasScope(Scopes.Roles) == true)
yield return Destinations.IdentityToken;
yield break;
/// <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;
default:
yield return Destinations.AccessToken;
yield break;
}
}
/// <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>
@@ -435,3 +337,5 @@ public class LogoutResponse
/// </summary>
public string Message { get; set; } = string.Empty;
}
#endregion

View File

@@ -1,246 +0,0 @@
// EN: Authorization Controller for OAuth2 Authorization Code Flow
// VI: Authorization Controller cho OAuth2 Authorization Code Flow
using System.Collections.Immutable;
using System.Security.Claims;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using Swashbuckle.AspNetCore.Annotations;
using IamService.Domain.AggregatesModel.UserAggregate;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace IamService.API.Controllers;
/// <summary>
/// EN: Handles OAuth2 Authorization Code Flow with PKCE.
/// VI: Xử lý OAuth2 Authorization Code Flow với PKCE.
/// </summary>
[ApiController]
[SwaggerTag("OAuth2 Authorization endpoints - Authorization Code Flow with PKCE")]
public class AuthorizationController : ControllerBase
{
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IOpenIddictAuthorizationManager _authorizationManager;
private readonly IOpenIddictScopeManager _scopeManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<AuthorizationController> _logger;
public AuthorizationController(
IOpenIddictApplicationManager applicationManager,
IOpenIddictAuthorizationManager authorizationManager,
IOpenIddictScopeManager scopeManager,
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager,
ILogger<AuthorizationController> logger)
{
_applicationManager = applicationManager;
_authorizationManager = authorizationManager;
_scopeManager = scopeManager;
_signInManager = signInManager;
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// EN: OAuth2 Authorization endpoint - initiates the authorization flow.
/// VI: OAuth2 Authorization endpoint - khởi tạo authorization flow.
/// </summary>
/// <returns>Challenge or Authorization ticket</returns>
[HttpGet("~/connect/authorize")]
[HttpPost("~/connect/authorize")]
[IgnoreAntiforgeryToken]
[SwaggerOperation(
Summary = "OAuth2 Authorization Endpoint",
Description = "Initiates the OAuth2 Authorization Code flow with PKCE. Redirects to login if not authenticated.",
OperationId = "Authorize")]
[SwaggerResponse(StatusCodes.Status302Found, "Redirect to login or callback URL")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request parameters")]
public async Task<IActionResult> Authorize()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
// EN: Try to retrieve the user principal stored in the authentication cookie
// VI: Thử lấy user principal từ authentication cookie
var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);
// EN: If the user is not authenticated, challenge the user
// VI: Nếu user chưa xác thực, yêu cầu đăng nhập
if (!result.Succeeded)
{
_logger.LogInformation("User not authenticated, redirecting to login");
// EN: Build the authorization request URL to redirect back after login
// VI: Tạo URL authorization request để redirect sau khi login
return Challenge(
authenticationSchemes: IdentityConstants.ApplicationScheme,
properties: new AuthenticationProperties
{
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(
Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList())
});
}
// EN: Retrieve the user from the authentication result
// VI: Lấy user từ kết quả authentication
var user = await _userManager.GetUserAsync(result.Principal);
if (user == null)
{
_logger.LogWarning("User not found from authentication result");
return Challenge(
authenticationSchemes: IdentityConstants.ApplicationScheme,
properties: new AuthenticationProperties
{
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(
Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList())
});
}
// EN: Create the claims-based identity used in the authorization response
// VI: Tạo claims-based identity cho authorization response
var identity = new ClaimsIdentity(
authenticationType: TokenValidationParameters.DefaultAuthenticationType,
nameType: Claims.Name,
roleType: Claims.Role);
// EN: Add standard claims
// VI: Thêm standard claims
identity.AddClaim(Claims.Subject, user.Id.ToString());
identity.AddClaim(Claims.Name, user.UserName ?? user.Email ?? "");
identity.AddClaim(Claims.Email, user.Email ?? "");
identity.AddClaim(Claims.EmailVerified, (user.EmailConfirmed).ToString().ToLower());
if (!string.IsNullOrEmpty(user.FirstName))
{
identity.AddClaim(Claims.GivenName, user.FirstName);
}
if (!string.IsNullOrEmpty(user.LastName))
{
identity.AddClaim(Claims.FamilyName, user.LastName);
}
// EN: Add roles as claims
// VI: Thêm roles như claims
var roles = await _userManager.GetRolesAsync(user);
foreach (var role in roles)
{
identity.AddClaim(Claims.Role, role);
}
var principal = new ClaimsPrincipal(identity);
// EN: Set requested scopes as granted
// VI: Set requested scopes là granted
var scopes = request.GetScopes();
principal.SetScopes(scopes);
principal.SetResources(await _scopeManager.ListResourcesAsync(scopes).ToListAsync());
// EN: Create and store an authorization if needed
// VI: Tạo và lưu authorization nếu cần
var application = await _applicationManager.FindByClientIdAsync(request.ClientId!);
if (application != null)
{
var applicationId = await _applicationManager.GetIdAsync(application);
// EN: Create a permanent authorization to avoid requiring consent for every request
// VI: Tạo permanent authorization để tránh yêu cầu consent cho mọi request
var authorization = await _authorizationManager.CreateAsync(
principal: principal,
subject: user.Id.ToString(),
client: applicationId!,
type: AuthorizationTypes.Permanent,
scopes: scopes);
principal.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization));
}
_logger.LogInformation("Authorization granted for user {UserId} with scopes {Scopes}",
user.Id, string.Join(", ", scopes));
// EN: Returning a SignInResult will ask OpenIddict to issue the appropriate tokens
// VI: Returning SignInResult sẽ yêu cầu OpenIddict issue tokens phù hợp
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
/// <summary>
/// EN: OAuth2 Logout endpoint - ends the user session.
/// VI: OAuth2 Logout endpoint - kết thúc user session.
/// </summary>
[HttpGet("~/connect/logout")]
[HttpPost("~/connect/logout")]
[SwaggerOperation(
Summary = "OAuth2 Logout Endpoint",
Description = "Logs out the user and optionally redirects to post_logout_redirect_uri.",
OperationId = "Logout")]
[SwaggerResponse(StatusCodes.Status200OK, "Logged out successfully")]
[SwaggerResponse(StatusCodes.Status302Found, "Redirect to post logout URI")]
public async Task<IActionResult> Logout()
{
var request = HttpContext.GetOpenIddictServerRequest();
// EN: Sign out from Identity
// VI: Đăng xuất khỏi Identity
await _signInManager.SignOutAsync();
_logger.LogInformation("User logged out via OAuth2 logout endpoint");
// EN: If post_logout_redirect_uri was provided, redirect there
// VI: Nếu có post_logout_redirect_uri, redirect đến đó
if (request?.PostLogoutRedirectUri != null)
{
return Redirect(request.PostLogoutRedirectUri);
}
// EN: Return sign out result
// VI: Return kết quả sign out
return SignOut(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties
{
RedirectUri = "/"
});
}
/// <summary>
/// EN: Validates that the current user can be authenticated.
/// VI: Xác thực rằng user hiện tại có thể được xác thực.
/// </summary>
[HttpGet("~/connect/authorize/callback")]
[Authorize]
[SwaggerOperation(
Summary = "Authorization Callback",
Description = "Callback endpoint after successful authentication.",
OperationId = "AuthorizeCallback")]
[SwaggerResponse(StatusCodes.Status302Found, "Redirect back to authorization")]
public IActionResult Callback()
{
// EN: Redirect back to the authorization endpoint
// VI: Redirect về authorization endpoint
var returnUrl = Request.Query["returnUrl"].FirstOrDefault();
if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
return Redirect("/connect/authorize" + Request.QueryString);
}
}
/// <summary>
/// EN: Extension methods for ClaimsIdentity
/// VI: Extension methods cho ClaimsIdentity
/// </summary>
internal static class ClaimsIdentityExtensions
{
public static void AddClaim(this ClaimsIdentity identity, string type, string value)
{
identity.AddClaim(new Claim(type, value));
}
}

View File

@@ -1,8 +1,8 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Validation.AspNetCore;
using Swashbuckle.AspNetCore.Annotations;
using IamService.API.Application.Common;
using IamService.API.Application.Commands.Roles;
@@ -17,7 +17,7 @@ namespace IamService.API.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/roles")]
[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[SwaggerTag("Role management endpoints - requires authentication")]
public class RolesController : ControllerBase
{

View File

@@ -1,8 +1,8 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Validation.AspNetCore;
using Swashbuckle.AspNetCore.Annotations;
using IamService.API.Application.Common;
using IamService.API.Application.Commands.Users;
@@ -17,7 +17,7 @@ namespace IamService.API.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/users")]
[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[SwaggerTag("User management endpoints - requires authentication")]
public class UsersController : ControllerBase
{

View File

@@ -45,9 +45,8 @@
<!-- EN: ASP.NET Core Identity for user management / VI: ASP.NET Core Identity cho quản lý user -->
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<!-- EN: OpenIddict for OAuth2/OIDC / VI: OpenIddict cho OAuth2/OIDC -->
<PackageReference Include="OpenIddict.AspNetCore" Version="5.8.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.8.0" />
<!-- EN: Duende IdentityServer for OAuth2/OIDC / VI: Duende IdentityServer cho OAuth2/OIDC -->
<PackageReference Include="Duende.IdentityServer.AspNetIdentity" Version="7.0.8" />
<!-- EN: JWT Bearer Authentication / VI: JWT Bearer Authentication -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />

View File

@@ -16,8 +16,8 @@ builder.Host.UseSerilog((context, services, configuration) => configuration
.Enrich.FromLogContext()
.WriteTo.Console());
// EN: Add Infrastructure services (Identity, OpenIddict, Repositories)
// VI: Thêm Infrastructure services (Identity, OpenIddict, Repositories)
// EN: Add Infrastructure services (Identity, Duende IdentityServer, Repositories)
// VI: Thêm Infrastructure services (Identity, Duende IdentityServer, Repositories)
builder.Services.AddInfrastructure(builder.Configuration, builder.Environment.EnvironmentName);
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
@@ -263,6 +263,7 @@ app.UseCors();
app.UseRouting();
// EN: Authentication and Authorization / VI: Xác thực và phân quyền
app.UseIdentityServer();
app.UseAuthentication();
app.UseAuthorization();

View File

@@ -0,0 +1 @@
{"alg":"RS256","d":"Axq4HZJr2EmWq0nvxDAcq8-1JBcYlp5Zppl408TsOUen6PRJEu53jdeRgIg0fpSPc_eDaaFS7bBpfxfZOQDJufDC3BJYXfjizFN_NniJH6JBYwtr7aobhGddaFxQtZBFYpmxXnyYzqiSswqjigD5ByKoFlASw45Wcu7-Zwvopr0sjHSlFuM0GFWS6CCu46CW7BFu6veLC3aRAcrFnwEuU3_I2kwV40BnUWB6D1CUglfZ8Fqsj3yiVGhnN-aYO4yN8uon2Smbz-z1q_ckNpG0Q4GebJ6b7-bHOM9Mk68oMY69tE0DW-c6UeDX_qFchxBi0xgWePezpOPQUc1kHRxEQw","dp":"yo5sGTxkNeR7YJd_2E_dTq_EzsU-mGmLHzSW3nG8_7zNtbniWwTFG-d4R-qBGpiMe3NWRRk2hhboIwsGsjO82wiufA6u5Chzzbj-vsX-9kkYIaOEiecOePoDMuUR63jkho7y-yQj0zRX3ljSmDUx2WsGp4c-bha33iXNS_qAyyc","dq":"pXJ4HBoexLAldTJOweMUQFkzlGXJFSRy_NWYyNTwLdpHC60IXQdAyvGVt68GtNYLRtDSBCXhmRkSdUJex2pCtGtQ2vDkdRgF1Ip4JbUm3KkbmgwtVq4LhwYxbuWd2vKSTK1EuOifk0Eakmj4ta1fVifGnmAw5HYGAZ1wY9NxCW8","e":"AQAB","key_ops":[],"kid":"9F395D43023373C4404FD48CF0F45A06","kty":"RSA","n":"w7hjkNEwExHYEt2omqEG__halBFn-tAql7vjPtxpdFWA09eeSImKHJkFEsSIIrzmJerp_nUd7N_oPGvx7QonVjE0ibd1Lj0E2sMuKwkipJpq9L9YzWZe8NNBUVMiUzwzcHFHGADPq59Nmp5-_Qwywjso_y10w8-7W1pXb8LZnjGaRKghCY_7--0uFLXyG9fWjfqTEtXhsdcAevf9wSaNzATBzJFXiAQxk_8QPqQRLsqic8HS1OcwTBRx8s7xGdDeiNEW-G5IwoCLRCXWTraR8uUAQjOrmoVzJYxWjyJbF0G8ixz9GrAwtKUF-3sVWVKuS-XarKjSSwCpL1wogx6_LQ","oth":[],"p":"84TJPKJOEduhN-lZEhCMICoEwUiQ98Sx8v2I7gpIKumLk3rf4y4yR6f-1R0sJOG6qC-u4_nMoWc6BTpBFa8ebgmYvFH4fXo51pyxdMg2pTz7LjaDc4cMXXSJBLrygE1RKRy0jDjkt5TxVlUIUlmtceiffhOSwWuLulrZQreJpYc","q":"zcBwTgvRft2D7d6geQafL8OMqiozq_zBcWNiJ0nRny1QFwa372dqld-85B_005HOhdoGo_MRPrjWFXc3A2aQFcDTcNwI8q1tWLCc2Ujztrmi6UJ5x2dmF2TJWbazJdlZDdF6oC_XfEybNp0gGTZW01uJ8nDmoYkGjSeTiqyU4qs","qi":"UGVc3HKHzW7plswnG0aiq6UbAZdYqoWfMlcQhvMML1nHDk9ecsKZL7KuKUkSnzL3sE7vmGb5q7uHAVLmrPtWOvLVkP9CMDtNC6db_aj1ilp_te7ulwDO1JUG6zcM27Se6i75XIj0rjY4ZGB4Nqi1kn0niB3rJCf2nt0i1E3FPas","x5c":[]}

View File

@@ -1,189 +0,0 @@
// EN: OpenIddict client seeder for development
// VI: OpenIddict client seeder cho development
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenIddict.Abstractions;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace IamService.Infrastructure.Data;
/// <summary>
/// EN: Background service to seed OpenIddict OAuth2 clients on startup.
/// VI: Background service để seed OpenIddict OAuth2 clients khi startup.
/// </summary>
public class OpenIddictClientSeeder : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<OpenIddictClientSeeder> _logger;
public OpenIddictClientSeeder(
IServiceProvider serviceProvider,
ILogger<OpenIddictClientSeeder> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await using var scope = _serviceProvider.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<IamServiceContext>();
await context.Database.EnsureCreatedAsync(cancellationToken);
var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
// EN: Seed web-app client (Authorization Code + PKCE)
// VI: Seed web-app client (Authorization Code + PKCE)
await SeedWebAppClientAsync(manager, cancellationToken);
// EN: Seed mobile-app client (Authorization Code + PKCE for native apps)
// VI: Seed mobile-app client (Authorization Code + PKCE cho native apps)
await SeedMobileAppClientAsync(manager, cancellationToken);
// EN: Seed service client (Client Credentials)
// VI: Seed service client (Client Credentials)
await SeedServiceClientAsync(manager, cancellationToken);
_logger.LogInformation("OpenIddict clients seeded successfully");
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task SeedWebAppClientAsync(
IOpenIddictApplicationManager manager,
CancellationToken cancellationToken)
{
const string clientId = "web-app";
if (await manager.FindByClientIdAsync(clientId, cancellationToken) != null)
{
_logger.LogDebug("Client {ClientId} already exists, skipping", clientId);
return;
}
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = clientId,
ClientSecret = "web-app-secret", // EN: Use env variable in production / VI: Dùng env variable trong production
DisplayName = "Web Application",
ConsentType = ConsentTypes.Implicit,
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Logout,
Permissions.Endpoints.Token,
Permissions.Endpoints.Revocation,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.RefreshToken,
Permissions.ResponseTypes.Code,
Permissions.Scopes.Email,
Permissions.Scopes.Profile,
Permissions.Scopes.Roles,
Permissions.Prefixes.Scope + "api",
Permissions.Prefixes.Scope + "offline_access"
},
RedirectUris =
{
new Uri("http://localhost:3000/auth/callback"),
new Uri("http://localhost:5173/auth/callback"),
new Uri("https://localhost:3000/auth/callback")
},
PostLogoutRedirectUris =
{
new Uri("http://localhost:3000"),
new Uri("http://localhost:5173"),
new Uri("https://localhost:3000")
},
Requirements =
{
Requirements.Features.ProofKeyForCodeExchange // EN: PKCE required / VI: PKCE bắt buộc
}
}, cancellationToken);
_logger.LogInformation("Created OAuth2 client: {ClientId}", clientId);
}
private async Task SeedMobileAppClientAsync(
IOpenIddictApplicationManager manager,
CancellationToken cancellationToken)
{
const string clientId = "mobile-app";
if (await manager.FindByClientIdAsync(clientId, cancellationToken) != null)
{
_logger.LogDebug("Client {ClientId} already exists, skipping", clientId);
return;
}
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = clientId,
// EN: No client secret for native/mobile apps (public client)
// VI: Không có client secret cho native/mobile apps (public client)
ClientType = ClientTypes.Public,
DisplayName = "Mobile Application",
ConsentType = ConsentTypes.Implicit,
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Logout,
Permissions.Endpoints.Token,
Permissions.Endpoints.Revocation,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.RefreshToken,
Permissions.ResponseTypes.Code,
Permissions.Scopes.Email,
Permissions.Scopes.Profile,
Permissions.Scopes.Roles,
Permissions.Prefixes.Scope + "api",
Permissions.Prefixes.Scope + "offline_access"
},
RedirectUris =
{
// EN: Custom scheme for mobile apps
// VI: Custom scheme cho mobile apps
new Uri("com.goodgo.app://oauth/callback"),
new Uri("goodgo://auth/callback")
},
Requirements =
{
Requirements.Features.ProofKeyForCodeExchange // EN: PKCE required / VI: PKCE bắt buộc
}
}, cancellationToken);
_logger.LogInformation("Created OAuth2 client: {ClientId}", clientId);
}
private async Task SeedServiceClientAsync(
IOpenIddictApplicationManager manager,
CancellationToken cancellationToken)
{
const string clientId = "service-client";
if (await manager.FindByClientIdAsync(clientId, cancellationToken) != null)
{
_logger.LogDebug("Client {ClientId} already exists, skipping", clientId);
return;
}
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = clientId,
ClientSecret = "service-client-secret", // EN: Use env variable in production / VI: Dùng env variable trong production
ClientType = ClientTypes.Confidential,
DisplayName = "Service to Service Client",
Permissions =
{
Permissions.Endpoints.Token,
Permissions.Endpoints.Introspection,
Permissions.GrantTypes.ClientCredentials,
Permissions.Prefixes.Scope + "api"
}
}, cancellationToken);
_logger.LogInformation("Created OAuth2 client: {ClientId}", clientId);
}
}

View File

@@ -1,3 +1,6 @@
// EN: Dependency injection extensions for Infrastructure layer.
// VI: Dependency injection extensions cho Infrastructure layer.
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -5,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Domain.AggregatesModel.RoleAggregate;
using IamService.Domain.SeedWork;
using IamService.Infrastructure.Data;
using IamService.Infrastructure.IdentityServer;
using IamService.Infrastructure.Repositories;
namespace IamService.Infrastructure;
@@ -56,10 +59,6 @@ public static class DependencyInjection
maxRetryDelay: TimeSpan.FromSeconds(10),
errorCodesToAdd: null);
});
// EN: Use OpenIddict EF Core stores
// VI: Sử dụng OpenIddict EF Core stores
options.UseOpenIddict();
});
}
@@ -89,71 +88,59 @@ public static class DependencyInjection
.AddEntityFrameworkStores<IamServiceContext>()
.AddDefaultTokenProviders();
// EN: Configure OpenIddict
// VI: Cấu hình OpenIddict
services.AddOpenIddict()
// EN: Register the OpenIddict core components
// VI: Đăng ký OpenIddict core components
.AddCore(options =>
// EN: Configure Duende IdentityServer
// VI: Cấu hình Duende IdentityServer
services.AddIdentityServer(options =>
{
// EN: Events for logging
// VI: Events để logging
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
// EN: Emit static audience claim
// VI: Emit static audience claim
options.EmitStaticAudienceClaim = true;
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryApiResources(Config.ApiResources)
.AddInMemoryClients(Config.Clients)
.AddAspNetIdentity<ApplicationUser>()
.AddDeveloperSigningCredential(); // EN: Use certificate in production / VI: Dùng certificate trong production
// EN: Add JWT Bearer authentication for API endpoints
// VI: Thêm JWT Bearer authentication cho API endpoints
services.AddAuthentication()
.AddJwtBearer(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<IamServiceContext>();
})
// EN: Register the OpenIddict server components
// VI: Đăng ký OpenIddict server components
.AddServer(options =>
{
// EN: Enable Authorization Code Flow endpoints
// VI: Bật Authorization Code Flow endpoints
options.SetAuthorizationEndpointUris("/connect/authorize")
.SetLogoutEndpointUris("/connect/logout")
.SetTokenEndpointUris("/connect/token")
.SetUserinfoEndpointUris("/connect/userinfo")
.SetIntrospectionEndpointUris("/connect/introspect")
.SetRevocationEndpointUris("/connect/revoke");
// EN: Enable flows - including Authorization Code + PKCE
// VI: Bật flows - bao gồm Authorization Code + PKCE
options.AllowAuthorizationCodeFlow()
.RequireProofKeyForCodeExchange() // EN: PKCE required / VI: PKCE bắt buộc
.AllowPasswordFlow()
.AllowRefreshTokenFlow()
.AllowClientCredentialsFlow();
// EN: Register scopes
// VI: Đăng ký scopes
options.RegisterScopes("openid", "profile", "email", "roles", "api", "offline_access");
// EN: Token lifetimes
// VI: Thời hạn token
options.SetAccessTokenLifetime(TimeSpan.FromMinutes(15))
.SetRefreshTokenLifetime(TimeSpan.FromDays(7));
// EN: Development settings
// VI: Cài đặt development
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate()
.DisableAccessTokenEncryption(); // EN: Disable encryption for dev / VI: Tắt mã hóa cho dev
// EN: Accept anonymous clients (for password flow without client_id)
// VI: Chấp nhận anonymous clients (cho password flow không cần client_id)
options.AcceptAnonymousClients();
// EN: Configure ASP.NET Core integration
// VI: Cấu hình tích hợp ASP.NET Core
options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableLogoutEndpointPassthrough()
.EnableTokenEndpointPassthrough()
.EnableUserinfoEndpointPassthrough()
.DisableTransportSecurityRequirement(); // EN: Allow HTTP for dev / VI: Cho phép HTTP cho dev
})
// EN: Register the OpenIddict validation components
// VI: Đăng ký OpenIddict validation components
.AddValidation(options =>
{
options.UseLocalServer();
options.UseAspNetCore();
// EN: Configure to validate tokens from local IdentityServer
// VI: Cấu hình để validate tokens từ IdentityServer cục bộ
options.Authority = configuration["IdentityServer:Authority"] ?? "https://localhost:5001";
options.Audience = "iam-api";
options.RequireHttpsMetadata = false; // EN: Set to true in production / VI: Đặt true trong production
// EN: Disable metadata validation for testing/development
// VI: Tắt metadata validation cho testing/development
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
ValidateAudience = false, // EN: Disable for flexibility / VI: Tắt để linh hoạt
ValidateIssuer = false, // EN: Disable for testing / VI: Tắt cho testing
ValidateLifetime = true,
RequireSignedTokens = true,
ValidateIssuerSigningKey = true
};
// EN: Disable metadata address requirement if in testing environment
// VI: Tắt yêu cầu metadata address nếu trong testing environment
if (string.Equals(environmentName, "Testing", StringComparison.OrdinalIgnoreCase))
{
options.MetadataAddress = string.Empty;
options.TokenValidationParameters.ValidateIssuerSigningKey = false;
options.TokenValidationParameters.RequireSignedTokens = false;
options.TokenValidationParameters.SignatureValidator = (token, parameters) => new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(token);
}
});
// EN: Register repositories
@@ -171,21 +158,13 @@ public static class DependencyInjection
services.AddSingleton<StackExchange.Redis.IConnectionMultiplexer>(sp =>
{
var connectionString = redisSettings.GetConnectionString();
return StackExchange.Redis.ConnectionMultiplexer.Connect(connectionString);
var connString = redisSettings.GetConnectionString();
return StackExchange.Redis.ConnectionMultiplexer.Connect(connString);
});
services.AddSingleton<Caching.ICacheService, Caching.RedisCacheService>();
}
// EN: Register OpenIddict client seeder (skip in Testing environment)
// VI: Đăng ký OpenIddict client seeder (bỏ qua trong Testing environment)
if (!string.Equals(environmentName, "Testing", StringComparison.OrdinalIgnoreCase))
{
services.AddHostedService<OpenIddictClientSeeder>();
}
return services;
}
}

View File

@@ -31,9 +31,13 @@
<!-- EN: ASP.NET Core Identity / VI: ASP.NET Core Identity -->
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<!-- EN: OpenIddict for OAuth2/OIDC / VI: OpenIddict cho OAuth2/OIDC -->
<PackageReference Include="OpenIddict.AspNetCore" Version="5.8.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.8.0" />
<!-- EN: Duende IdentityServer for OAuth2/OIDC / VI: Duende IdentityServer cho OAuth2/OIDC -->
<PackageReference Include="Duende.IdentityServer" Version="7.0.8" />
<PackageReference Include="Duende.IdentityServer.AspNetIdentity" Version="7.0.8" />
<PackageReference Include="Duende.IdentityServer.EntityFramework" Version="7.0.8" />
<!-- EN: JWT Bearer for API authentication / VI: JWT Bearer cho API authentication -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -10,8 +10,8 @@ using IamService.Domain.SeedWork;
namespace IamService.Infrastructure;
/// <summary>
/// EN: Database context for IAM Service with Identity and OpenIddict support.
/// VI: Database context cho IAM Service với Identity và OpenIddict support.
/// EN: Database context for IAM Service with Identity support.
/// VI: Database context cho IAM Service với Identity support.
/// </summary>
public class IamServiceContext : IdentityDbContext<ApplicationUser, ApplicationRole, Guid>, IUnitOfWork
{
@@ -69,10 +69,6 @@ public class IamServiceContext : IdentityDbContext<ApplicationUser, ApplicationR
UserStatus.Disabled,
UserStatus.PendingVerification
);
// EN: Configure OpenIddict entities
// VI: Cấu hình OpenIddict entities
modelBuilder.UseOpenIddict();
}
/// <summary>

View File

@@ -0,0 +1,171 @@
// EN: IdentityServer Configuration - Clients, Scopes, Resources
// VI: IdentityServer Configuration - Clients, Scopes, Resources
using Duende.IdentityServer;
using Duende.IdentityServer.Models;
namespace IamService.Infrastructure.IdentityServer;
/// <summary>
/// EN: Static configuration for IdentityServer.
/// VI: Static configuration cho IdentityServer.
/// </summary>
public static class Config
{
/// <summary>
/// EN: Identity resources (claims about the user).
/// VI: Identity resources (claims về user).
/// </summary>
public static IEnumerable<IdentityResource> IdentityResources =>
[
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Email(),
new IdentityResource("roles", "User roles", ["role"])
];
/// <summary>
/// EN: API scopes for protecting APIs.
/// VI: API scopes để bảo vệ APIs.
/// </summary>
public static IEnumerable<ApiScope> ApiScopes =>
[
new ApiScope("api", "IAM Service API")
{
UserClaims = ["role", "email", "name"]
}
];
/// <summary>
/// EN: API resources (logical grouping of scopes).
/// VI: API resources (nhóm logic của scopes).
/// </summary>
public static IEnumerable<ApiResource> ApiResources =>
[
new ApiResource("iam-api", "IAM Service API")
{
Scopes = { "api" },
UserClaims = { "role", "email", "name" }
}
];
/// <summary>
/// EN: OAuth2/OIDC Clients.
/// VI: OAuth2/OIDC Clients.
/// </summary>
public static IEnumerable<Client> Clients =>
[
// EN: Web Application - Authorization Code + PKCE
// VI: Web Application - Authorization Code + PKCE
new Client
{
ClientId = "web-app",
ClientName = "Web Application",
ClientSecrets = { new Secret("web-app-secret".Sha256()) },
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = true,
RedirectUris =
{
"http://localhost:3000/auth/callback",
"http://localhost:5173/auth/callback",
"https://localhost:3000/auth/callback"
},
PostLogoutRedirectUris =
{
"http://localhost:3000",
"http://localhost:5173",
"https://localhost:3000"
},
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
IdentityServerConstants.StandardScopes.OfflineAccess,
"roles",
"api"
},
AllowOfflineAccess = true,
AccessTokenLifetime = 900, // 15 minutes
RefreshTokenExpiration = TokenExpiration.Sliding,
SlidingRefreshTokenLifetime = 604800 // 7 days
},
// EN: Mobile Application - Authorization Code + PKCE (Public Client)
// VI: Mobile Application - Authorization Code + PKCE (Public Client)
new Client
{
ClientId = "mobile-app",
ClientName = "Mobile Application",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = false, // EN: Public client / VI: Public client
RedirectUris =
{
"com.goodgo.app://oauth/callback",
"goodgo://auth/callback"
},
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
IdentityServerConstants.StandardScopes.OfflineAccess,
"roles",
"api"
},
AllowOfflineAccess = true,
AccessTokenLifetime = 900,
RefreshTokenExpiration = TokenExpiration.Sliding,
SlidingRefreshTokenLifetime = 604800
},
// EN: Service-to-Service - Client Credentials
// VI: Service-to-Service - Client Credentials
new Client
{
ClientId = "service-client",
ClientName = "Service to Service Client",
ClientSecrets = { new Secret("service-client-secret".Sha256()) },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "api" }
},
// EN: Resource Owner Password (for legacy/testing)
// VI: Resource Owner Password (cho legacy/testing)
new Client
{
ClientId = "password-client",
ClientName = "Password Grant Client",
ClientSecrets = { new Secret("password-client-secret".Sha256()) },
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
IdentityServerConstants.StandardScopes.OfflineAccess,
"roles",
"api"
},
AllowOfflineAccess = true,
AccessTokenLifetime = 900,
RefreshTokenExpiration = TokenExpiration.Sliding,
SlidingRefreshTokenLifetime = 604800
}
];
}

View File

@@ -152,13 +152,15 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
await userManager.UpdateAsync(user);
}
// Act - Login
// Act - Login using Duende IdentityServer format
var tokenRequest = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["client_id"] = "password-client",
["client_secret"] = "password-client-secret",
["username"] = email,
["password"] = password,
["scope"] = "openid profile email offline_access"
["scope"] = "openid profile email api offline_access"
});
var response = await _client.PostAsync("/connect/token", tokenRequest);
@@ -182,13 +184,15 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
[Fact]
public async Task Token_WithInvalidCredentials_ShouldReturn400()
{
// Arrange
// Arrange - using Duende IdentityServer format
var tokenRequest = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["client_id"] = "password-client",
["client_secret"] = "password-client-secret",
["username"] = "nonexistent@example.com",
["password"] = "WrongPassword123!",
["scope"] = "openid"
["scope"] = "openid api"
});
// Act

View File

@@ -52,13 +52,15 @@ public class UsersControllerTests : IClassFixture<CustomWebApplicationFactory>
await userManager.UpdateAsync(user);
}
// Get token
// Get token using Duende IdentityServer format (includes client credentials)
var tokenRequest = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["client_id"] = "password-client",
["client_secret"] = "password-client-secret",
["username"] = email,
["password"] = password,
["scope"] = "openid profile email offline_access"
["scope"] = "openid profile email api offline_access"
});
var response = await _client.PostAsync("/connect/token", tokenRequest);

View File

@@ -6,13 +6,16 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using IamService.Infrastructure;
using IamService.Infrastructure.Caching;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Domain.AggregatesModel.RoleAggregate;
using Microsoft.AspNetCore.Identity;
using StackExchange.Redis;
namespace IamService.FunctionalTests;
/// <summary>
/// EN: Custom WebApplicationFactory for functional tests.
/// VI: WebApplicationFactory tùy chỉnh cho functional tests.
/// EN: Custom WebApplicationFactory for functional tests with Duende IdentityServer.
/// VI: WebApplicationFactory tùy chỉnh cho functional tests với Duende IdentityServer.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
@@ -34,10 +37,6 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
// VI: Xóa các đăng ký liên quan đến Redis
RemoveRedisRegistrations(services);
// EN: Remove OpenIddict EF Core stores to re-register with in-memory db
// VI: Xóa OpenIddict EF Core stores để đăng ký lại với in-memory db
RemoveOpenIddictStores(services);
// EN: Add mock cache service for testing
// VI: Thêm mock cache service để test
services.AddSingleton<ICacheService, InMemoryCacheService>();
@@ -47,26 +46,16 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
services.AddDbContext<IamServiceContext>(options =>
{
options.UseInMemoryDatabase(_databaseName);
options.UseOpenIddict();
options.EnableSensitiveDataLogging();
});
// EN: Re-register OpenIddict Core with the new DbContext
// VI: Đăng ký lại OpenIddict Core với DbContext mới
services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<IamServiceContext>();
});
// EN: Set logging level for debugging tests
// VI: Đặt mức logging để debug tests
services.AddLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Warning);
logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
logging.AddFilter("OpenIddict", LogLevel.Warning);
logging.AddFilter("Duende.IdentityServer", LogLevel.Warning);
});
});
}
@@ -109,21 +98,6 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
}
}
private static void RemoveOpenIddictStores(IServiceCollection services)
{
// EN: Remove OpenIddict EF Core store registrations to re-register with in-memory db
// VI: Xóa các đăng ký OpenIddict EF Core stores để đăng ký lại với in-memory db
var openIddictDescriptors = services.Where(d =>
d.ServiceType.FullName?.Contains("OpenIddict.EntityFrameworkCore") == true ||
d.ImplementationType?.FullName?.Contains("OpenIddict.EntityFrameworkCore") == true)
.ToList();
foreach (var descriptor in openIddictDescriptors)
{
services.Remove(descriptor);
}
}
/// <summary>
/// EN: Ensure database is created after host is built
/// VI: Đảm bảo database được tạo sau khi host được build

View File

@@ -21,6 +21,9 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" />
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<!-- EN: Duende IdentityServer for testing / VI: Duende IdentityServer cho testing -->
<PackageReference Include="Duende.IdentityServer.AspNetIdentity" Version="7.0.8" />
<!-- EN: Test containers for database / VI: Test containers cho database -->
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />