diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommandHandler.cs index 655a56b3..d18b483e 100644 --- a/services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommandHandler.cs +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Auth/LogoutCommandHandler.cs @@ -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; /// -/// 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. /// public class LogoutCommandHandler : IRequestHandler { private readonly UserManager _userManager; - private readonly IOpenIddictTokenManager _tokenManager; + private readonly SignInManager _signInManager; private readonly ILogger _logger; public LogoutCommandHandler( UserManager userManager, - IOpenIddictTokenManager tokenManager, + SignInManager signInManager, ILogger logger) { _userManager = userManager; - _tokenManager = tokenManager; + _signInManager = signInManager; _logger = logger; } @@ -41,24 +43,17 @@ public class LogoutCommandHandler : IRequestHandler -/// 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. /// [ApiController] [ApiVersion("1.0")] @@ -29,17 +31,23 @@ public class AuthController : ControllerBase private readonly IMediator _mediator; private readonly UserManager _userManager; private readonly SignInManager _signInManager; + private readonly IIdentityServerInteractionService _interaction; + private readonly IEventService _events; private readonly ILogger _logger; public AuthController( IMediator mediator, UserManager userManager, SignInManager signInManager, + IIdentityServerInteractionService interaction, + IEventService events, ILogger 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.Ok(result)); } - /// - /// EN: OAuth2 Token endpoint - supports password, refresh_token, and client_credentials grants. - /// VI: OAuth2 Token endpoint - hỗ trợ password, refresh_token, và client_credentials grants. + /// EN: Login with email and password (Resource Owner Password Grant). + /// VI: Đăng nhập với email và password (Resource Owner Password Grant). /// - /// - /// **Password Grant (Login):** - /// ``` - /// POST /connect/token - /// Content-Type: application/x-www-form-urlencoded - /// - /// grant_type=password&username=user@example.com&password=YourPassword&scope=openid profile email roles api - /// ``` - /// - /// **Refresh Token Grant:** - /// ``` - /// POST /connect/token - /// Content-Type: application/x-www-form-urlencoded - /// - /// grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN - /// ``` - /// - /// **Client Credentials Grant:** - /// ``` - /// POST /connect/token - /// Content-Type: application/x-www-form-urlencoded - /// - /// grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&scope=api - /// ``` - /// - /// OAuth2 token response with access_token, refresh_token, expires_in - [HttpPost("~/connect/token")] - [Consumes("application/x-www-form-urlencoded")] - [Produces("application/json")] + /// Login credentials + /// Cancellation token + /// Login result with token info + [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), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task Exchange() + public async Task 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 - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.UnsupportedGrantType, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The specified grant type is not supported." - })); - } - - private async Task HandlePasswordGrantAsync(OpenIddictRequest request) - { - var user = await _userManager.FindByEmailAsync(request.Username!); + 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 - { - [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.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 - { - [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.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 - { - [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.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 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.Ok(new LoginResponse { - return Forbid( - authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, - properties: new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The refresh token is no longer valid." - })); - } - - var user = await _userManager.FindByIdAsync(userId); - if (user == null) - { - return Forbid( - authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, - properties: new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user no longer exists." - })); - } - - // EN: Recreate principal with updated claims - // VI: Tạo lại principal với claims đã cập nhật - var identity = new ClaimsIdentity(result.Principal!.Claims, - authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, - nameType: Claims.Name, - roleType: Claims.Role); - - identity.SetDestinations(GetDestinations); - - return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - } - - private Task HandleClientCredentialsGrantAsync(OpenIddictRequest request) - { - var identity = new ClaimsIdentity( - authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, - nameType: Claims.Name, - roleType: Claims.Role); - - identity.SetClaim(Claims.Subject, request.ClientId); - - identity.SetDestinations(GetDestinations); - - var principal = new ClaimsPrincipal(identity); - principal.SetScopes(request.GetScopes()); - - return Task.FromResult( - SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)); + Success = true, + Message = "Login successful. Use /connect/token with grant_type=password for access tokens.", + UserId = user.Id, + Email = user.Email!, + FullName = user.FullName + })); } /// @@ -292,7 +154,7 @@ public class AuthController : ControllerBase /// Cancellation token /// Result of password change operation [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 /// Cancellation token /// Result of logout operation [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 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 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; +/// +/// EN: Login request body. +/// VI: Request body đăng nhập. +/// +public class LoginRequest +{ + /// + /// EN: User email. + /// VI: Email người dùng. + /// + /// user@example.com + public string Email { get; set; } = string.Empty; - default: - yield return Destinations.AccessToken; - yield break; - } - } + /// + /// EN: User password. + /// VI: Mật khẩu người dùng. + /// + /// Password123! + public string Password { get; set; } = string.Empty; +} + +/// +/// EN: Login response. +/// VI: Response đăng nhập. +/// +public class LoginResponse +{ + /// + /// EN: Whether the login was successful. + /// VI: Đăng nhập có thành công không. + /// + public bool Success { get; set; } + + /// + /// EN: Result message. + /// VI: Thông điệp kết quả. + /// + public string Message { get; set; } = string.Empty; + + /// + /// EN: User ID. + /// VI: ID người dùng. + /// + public Guid UserId { get; set; } + + /// + /// EN: User email. + /// VI: Email người dùng. + /// + public string Email { get; set; } = string.Empty; + + /// + /// EN: User full name. + /// VI: Tên đầy đủ người dùng. + /// + public string FullName { get; set; } = string.Empty; } /// @@ -435,3 +337,5 @@ public class LogoutResponse /// public string Message { get; set; } = string.Empty; } + +#endregion diff --git a/services/iam-service-net/src/IamService.API/Controllers/AuthorizationController.cs b/services/iam-service-net/src/IamService.API/Controllers/AuthorizationController.cs deleted file mode 100644 index 89ff76de..00000000 --- a/services/iam-service-net/src/IamService.API/Controllers/AuthorizationController.cs +++ /dev/null @@ -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; - -/// -/// EN: Handles OAuth2 Authorization Code Flow with PKCE. -/// VI: Xử lý OAuth2 Authorization Code Flow với PKCE. -/// -[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 _signInManager; - private readonly UserManager _userManager; - private readonly ILogger _logger; - - public AuthorizationController( - IOpenIddictApplicationManager applicationManager, - IOpenIddictAuthorizationManager authorizationManager, - IOpenIddictScopeManager scopeManager, - SignInManager signInManager, - UserManager userManager, - ILogger logger) - { - _applicationManager = applicationManager; - _authorizationManager = authorizationManager; - _scopeManager = scopeManager; - _signInManager = signInManager; - _userManager = userManager; - _logger = logger; - } - - /// - /// EN: OAuth2 Authorization endpoint - initiates the authorization flow. - /// VI: OAuth2 Authorization endpoint - khởi tạo authorization flow. - /// - /// Challenge or Authorization ticket - [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 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); - } - - /// - /// EN: OAuth2 Logout endpoint - ends the user session. - /// VI: OAuth2 Logout endpoint - kết thúc user session. - /// - [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 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 = "/" - }); - } - - /// - /// 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. - /// - [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); - } -} - -/// -/// EN: Extension methods for ClaimsIdentity -/// VI: Extension methods cho ClaimsIdentity -/// -internal static class ClaimsIdentityExtensions -{ - public static void AddClaim(this ClaimsIdentity identity, string type, string value) - { - identity.AddClaim(new Claim(type, value)); - } -} diff --git a/services/iam-service-net/src/IamService.API/Controllers/RolesController.cs b/services/iam-service-net/src/IamService.API/Controllers/RolesController.cs index 51af8790..43220a37 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/RolesController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/RolesController.cs @@ -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 { diff --git a/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs b/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs index 36c57dc6..f408af00 100644 --- a/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs +++ b/services/iam-service-net/src/IamService.API/Controllers/UsersController.cs @@ -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 { diff --git a/services/iam-service-net/src/IamService.API/IamService.API.csproj b/services/iam-service-net/src/IamService.API/IamService.API.csproj index 82bb9de9..4e0512a7 100644 --- a/services/iam-service-net/src/IamService.API/IamService.API.csproj +++ b/services/iam-service-net/src/IamService.API/IamService.API.csproj @@ -45,9 +45,8 @@ - - - + + diff --git a/services/iam-service-net/src/IamService.API/Program.cs b/services/iam-service-net/src/IamService.API/Program.cs index ab5145b2..77aa76a6 100644 --- a/services/iam-service-net/src/IamService.API/Program.cs +++ b/services/iam-service-net/src/IamService.API/Program.cs @@ -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(); diff --git a/services/iam-service-net/src/IamService.API/tempkey.jwk b/services/iam-service-net/src/IamService.API/tempkey.jwk new file mode 100644 index 00000000..be76467e --- /dev/null +++ b/services/iam-service-net/src/IamService.API/tempkey.jwk @@ -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":[]} \ No newline at end of file diff --git a/services/iam-service-net/src/IamService.Infrastructure/Data/OpenIddictClientSeeder.cs b/services/iam-service-net/src/IamService.Infrastructure/Data/OpenIddictClientSeeder.cs deleted file mode 100644 index 7e354f66..00000000 --- a/services/iam-service-net/src/IamService.Infrastructure/Data/OpenIddictClientSeeder.cs +++ /dev/null @@ -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; - -/// -/// EN: Background service to seed OpenIddict OAuth2 clients on startup. -/// VI: Background service để seed OpenIddict OAuth2 clients khi startup. -/// -public class OpenIddictClientSeeder : IHostedService -{ - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - - public OpenIddictClientSeeder( - IServiceProvider serviceProvider, - ILogger logger) - { - _serviceProvider = serviceProvider; - _logger = logger; - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - await using var scope = _serviceProvider.CreateAsyncScope(); - - var context = scope.ServiceProvider.GetRequiredService(); - await context.Database.EnsureCreatedAsync(cancellationToken); - - var manager = scope.ServiceProvider.GetRequiredService(); - - // 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); - } -} diff --git a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs index a698fef4..206e4b67 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/DependencyInjection.cs @@ -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() .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() + .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(); - }) - // 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(sp => { - var connectionString = redisSettings.GetConnectionString(); - return StackExchange.Redis.ConnectionMultiplexer.Connect(connectionString); + var connString = redisSettings.GetConnectionString(); + return StackExchange.Redis.ConnectionMultiplexer.Connect(connString); }); services.AddSingleton(); } - // 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(); - } - return services; } } - diff --git a/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj b/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj index de68ed06..b8af21ed 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj +++ b/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj @@ -31,9 +31,13 @@ - - - + + + + + + + diff --git a/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs b/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs index 0a299a83..f0a544d0 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs @@ -10,8 +10,8 @@ using IamService.Domain.SeedWork; namespace IamService.Infrastructure; /// -/// 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. /// public class IamServiceContext : IdentityDbContext, IUnitOfWork { @@ -69,10 +69,6 @@ public class IamServiceContext : IdentityDbContext diff --git a/services/iam-service-net/src/IamService.Infrastructure/IdentityServer/Config.cs b/services/iam-service-net/src/IamService.Infrastructure/IdentityServer/Config.cs new file mode 100644 index 00000000..a65894f9 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/IdentityServer/Config.cs @@ -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; + +/// +/// EN: Static configuration for IdentityServer. +/// VI: Static configuration cho IdentityServer. +/// +public static class Config +{ + /// + /// EN: Identity resources (claims about the user). + /// VI: Identity resources (claims về user). + /// + public static IEnumerable IdentityResources => + [ + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + new IdentityResources.Email(), + new IdentityResource("roles", "User roles", ["role"]) + ]; + + /// + /// EN: API scopes for protecting APIs. + /// VI: API scopes để bảo vệ APIs. + /// + public static IEnumerable ApiScopes => + [ + new ApiScope("api", "IAM Service API") + { + UserClaims = ["role", "email", "name"] + } + ]; + + /// + /// EN: API resources (logical grouping of scopes). + /// VI: API resources (nhóm logic của scopes). + /// + public static IEnumerable ApiResources => + [ + new ApiResource("iam-api", "IAM Service API") + { + Scopes = { "api" }, + UserClaims = { "role", "email", "name" } + } + ]; + + /// + /// EN: OAuth2/OIDC Clients. + /// VI: OAuth2/OIDC Clients. + /// + public static IEnumerable 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 + } + ]; +} diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs index b8ae2289..82a6d184 100644 --- a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs +++ b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/AuthControllerTests.cs @@ -152,13 +152,15 @@ public class AuthControllerTests : IClassFixture await userManager.UpdateAsync(user); } - // Act - Login + // Act - Login using Duende IdentityServer format var tokenRequest = new FormUrlEncodedContent(new Dictionary { ["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 [Fact] public async Task Token_WithInvalidCredentials_ShouldReturn400() { - // Arrange + // Arrange - using Duende IdentityServer format var tokenRequest = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "password", + ["client_id"] = "password-client", + ["client_secret"] = "password-client-secret", ["username"] = "nonexistent@example.com", ["password"] = "WrongPassword123!", - ["scope"] = "openid" + ["scope"] = "openid api" }); // Act diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/UsersControllerTests.cs b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/UsersControllerTests.cs index da038fe5..e7e4665b 100644 --- a/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/UsersControllerTests.cs +++ b/services/iam-service-net/tests/IamService.FunctionalTests/Controllers/UsersControllerTests.cs @@ -52,13 +52,15 @@ public class UsersControllerTests : IClassFixture await userManager.UpdateAsync(user); } - // Get token + // Get token using Duende IdentityServer format (includes client credentials) var tokenRequest = new FormUrlEncodedContent(new Dictionary { ["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); diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs b/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs index 298319a7..aece5431 100644 --- a/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs +++ b/services/iam-service-net/tests/IamService.FunctionalTests/CustomWebApplicationFactory.cs @@ -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; /// -/// 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. /// public class CustomWebApplicationFactory : WebApplicationFactory { @@ -34,10 +37,6 @@ public class CustomWebApplicationFactory : WebApplicationFactory // 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(); @@ -47,26 +46,16 @@ public class CustomWebApplicationFactory : WebApplicationFactory services.AddDbContext(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(); - }); - // 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 } } - 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); - } - } - /// /// EN: Ensure database is created after host is built /// VI: Đảm bảo database được tạo sau khi host được build diff --git a/services/iam-service-net/tests/IamService.FunctionalTests/IamService.FunctionalTests.csproj b/services/iam-service-net/tests/IamService.FunctionalTests/IamService.FunctionalTests.csproj index e4a209e6..c0aa30c3 100644 --- a/services/iam-service-net/tests/IamService.FunctionalTests/IamService.FunctionalTests.csproj +++ b/services/iam-service-net/tests/IamService.FunctionalTests/IamService.FunctionalTests.csproj @@ -21,6 +21,9 @@ + + +