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:
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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&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
|
||||
/// ```
|
||||
/// </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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
1
services/iam-service-net/src/IamService.API/tempkey.jwk
Normal file
1
services/iam-service-net/src/IamService.API/tempkey.jwk
Normal 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":[]}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user