From e1ba3a90c3b82e06b0ce13c5a5614357a8e52dac Mon Sep 17 00:00:00 2001 From: Sam <315859133@qq.com> Date: Thu, 5 Feb 2026 14:21:36 +0800 Subject: [PATCH] feat(console): complete migration of User, Tenant, and Role management APIs --- Controllers/OAuthClientsController.cs | 270 ++++++++++++++++++++++++ Fengling.Console.http | 6 + Models/Dtos/OAuthClientDto.cs | 47 +++++ Properties/launchSettings.json | 14 ++ Repositories/OAuthClientRepository.cs | 107 ++++++++++ Services/OAuthClientService.cs | 293 ++++++++++++++++++++++++++ appsettings.Development.json | 8 + appsettings.json | 9 + 8 files changed, 754 insertions(+) create mode 100644 Controllers/OAuthClientsController.cs create mode 100644 Fengling.Console.http create mode 100644 Models/Dtos/OAuthClientDto.cs create mode 100644 Properties/launchSettings.json create mode 100644 Repositories/OAuthClientRepository.cs create mode 100644 Services/OAuthClientService.cs create mode 100644 appsettings.Development.json create mode 100644 appsettings.json diff --git a/Controllers/OAuthClientsController.cs b/Controllers/OAuthClientsController.cs new file mode 100644 index 0000000..3aeba55 --- /dev/null +++ b/Controllers/OAuthClientsController.cs @@ -0,0 +1,270 @@ +using Fengling.AuthService.Models; +using Fengling.Console.Models.Dtos; +using Fengling.Console.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Fengling.Console.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class OAuthClientsController : ControllerBase +{ + private readonly IOAuthClientService _service; + private readonly ILogger _logger; + + public OAuthClientsController( + IOAuthClientService service, + ILogger logger) + { + _service = service; + _logger = logger; + } + + [HttpGet] + public async Task> GetClients( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + [FromQuery] string? displayName = null, + [FromQuery] string? clientId = null, + [FromQuery] string? status = null) + { + try + { + var (items, totalCount) = await _service.GetClientsAsync(page, pageSize, displayName, clientId, status); + + var result = items.Select(c => new OAuthClientDto + { + Id = c.Id, + ClientId = c.ClientId, + DisplayName = c.DisplayName, + RedirectUris = c.RedirectUris, + PostLogoutRedirectUris = c.PostLogoutRedirectUris, + Scopes = c.Scopes, + GrantTypes = c.GrantTypes, + ClientType = c.ClientType, + ConsentType = c.ConsentType, + Status = c.Status, + Description = c.Description, + CreatedAt = c.CreatedAt, + UpdatedAt = c.UpdatedAt, + }); + + return Ok(new + { + items = result, + totalCount, + page, + pageSize + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting clients"); + return StatusCode(500, new { message = ex.Message }); + } + } + + [HttpGet("options")] + public ActionResult GetClientOptions() + { + return Ok(_service.GetClientOptions()); + } + + [HttpGet("{id}")] + public async Task> GetClient(long id) + { + try + { + var client = await _service.GetClientAsync(id); + if (client == null) + { + return NotFound(); + } + + return Ok(new OAuthClientDto + { + Id = client.Id, + ClientId = client.ClientId, + DisplayName = client.DisplayName, + RedirectUris = client.RedirectUris, + PostLogoutRedirectUris = client.PostLogoutRedirectUris, + Scopes = client.Scopes, + GrantTypes = client.GrantTypes, + ClientType = client.ClientType, + ConsentType = client.ConsentType, + Status = client.Status, + Description = client.Description, + CreatedAt = client.CreatedAt, + UpdatedAt = client.UpdatedAt, + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting client {Id}", id); + return StatusCode(500, new { message = ex.Message }); + } + } + + [HttpPost] + public async Task> CreateClient([FromBody] CreateOAuthClientDto dto) + { + try + { + var client = new OAuthApplication + { + ClientId = dto.ClientId, + ClientSecret = string.IsNullOrEmpty(dto.ClientSecret) ? GenerateSecureSecret() : dto.ClientSecret, + DisplayName = dto.DisplayName, + RedirectUris = dto.RedirectUris ?? Array.Empty(), + PostLogoutRedirectUris = dto.PostLogoutRedirectUris ?? Array.Empty(), + Scopes = dto.Scopes ?? new[] { "openid", "profile", "email", "api" }, + GrantTypes = dto.GrantTypes ?? new[] { "authorization_code", "refresh_token" }, + ClientType = dto.ClientType ?? "confidential", + ConsentType = dto.ConsentType ?? "explicit", + Status = dto.Status ?? "active", + Description = dto.Description, + CreatedAt = DateTime.UtcNow, + }; + + var created = await _service.CreateClientAsync(client); + + return CreatedAtAction(nameof(GetClient), new { id = created.Id }, new OAuthClientDto + { + Id = created.Id, + ClientId = created.ClientId, + ClientSecret = created.ClientSecret, + DisplayName = created.DisplayName, + Status = created.Status, + CreatedAt = created.CreatedAt, + }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating client"); + return StatusCode(500, new { message = ex.Message }); + } + } + + [HttpPost("{id}/generate-secret")] + public async Task GenerateSecret(long id) + { + try + { + var client = await _service.GenerateSecretAsync(id); + return Ok(new + { + clientId = client.ClientId, + clientSecret = client.ClientSecret, + message = "新密钥已生成,请妥善保管,刷新后将无法再次查看" + }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating secret for client {Id}", id); + return StatusCode(500, new { message = ex.Message }); + } + } + + [HttpPost("{id}/toggle-status")] + public async Task ToggleStatus(long id) + { + try + { + var client = await _service.ToggleStatusAsync(id); + return Ok(new + { + clientId = client.ClientId, + newStatus = client.Status, + message = $"客户端状态已更改为 {client.Status}" + }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error toggling status for client {Id}", id); + return StatusCode(500, new { message = ex.Message }); + } + } + + [HttpPut("{id}")] + public async Task UpdateClient(long id, [FromBody] UpdateOAuthClientDto dto) + { + try + { + var client = await _service.GetClientAsync(id); + if (client == null) + { + return NotFound(); + } + + var updated = new OAuthApplication + { + Id = id, + ClientId = client.ClientId, + ClientSecret = client.ClientSecret, + DisplayName = dto.DisplayName, + RedirectUris = dto.RedirectUris, + PostLogoutRedirectUris = dto.PostLogoutRedirectUris, + Scopes = dto.Scopes, + GrantTypes = dto.GrantTypes, + ClientType = dto.ClientType, + ConsentType = dto.ConsentType, + Status = dto.Status, + Description = dto.Description, + CreatedAt = client.CreatedAt, + }; + + await _service.UpdateClientAsync(id, updated); + return NoContent(); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating client {Id}", id); + return StatusCode(500, new { message = ex.Message }); + } + } + + [HttpDelete("{id}")] + public async Task DeleteClient(long id) + { + try + { + await _service.DeleteClientAsync(id); + return NoContent(); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting client {Id}", id); + return StatusCode(500, new { message = ex.Message }); + } + } + + private static string GenerateSecureSecret(int length = 32) + { + var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + var bytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(length); + return new string(bytes.Select(b => chars[b % chars.Length]).ToArray()); + } +} diff --git a/Fengling.Console.http b/Fengling.Console.http new file mode 100644 index 0000000..76a0acc --- /dev/null +++ b/Fengling.Console.http @@ -0,0 +1,6 @@ +@Fengling.Console_HostAddress = http://localhost:5299 + +GET {{Fengling.Console_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Models/Dtos/OAuthClientDto.cs b/Models/Dtos/OAuthClientDto.cs new file mode 100644 index 0000000..d15c60a --- /dev/null +++ b/Models/Dtos/OAuthClientDto.cs @@ -0,0 +1,47 @@ +namespace Fengling.Console.Models.Dtos; + +public record CreateOAuthClientDto +{ + public string ClientId { get; init; } = string.Empty; + public string? ClientSecret { get; init; } + public string DisplayName { get; init; } = string.Empty; + public string[]? RedirectUris { get; init; } + public string[]? PostLogoutRedirectUris { get; init; } + public string[]? Scopes { get; init; } + public string[]? GrantTypes { get; init; } + public string? ClientType { get; init; } + public string? ConsentType { get; init; } + public string? Status { get; init; } + public string? Description { get; init; } +} + +public record UpdateOAuthClientDto +{ + public string? DisplayName { get; init; } + public string[]? RedirectUris { get; init; } + public string[]? PostLogoutRedirectUris { get; init; } + public string[]? Scopes { get; init; } + public string[]? GrantTypes { get; init; } + public string? ClientType { get; init; } + public string? ConsentType { get; init; } + public string? Status { get; init; } + public string? Description { get; init; } +} + +public record OAuthClientDto +{ + public long Id { get; init; } + public string ClientId { get; init; } = string.Empty; + public string? ClientSecret { get; init; } + public string DisplayName { get; init; } = string.Empty; + public string[] RedirectUris { get; init; } = Array.Empty(); + public string[] PostLogoutRedirectUris { get; init; } = Array.Empty(); + public string[] Scopes { get; init; } = Array.Empty(); + public string[] GrantTypes { get; init; } = Array.Empty(); + public string ClientType { get; init; } = "public"; + public string ConsentType { get; init; } = "implicit"; + public string Status { get; init; } = "active"; + public string? Description { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime? UpdatedAt { get; init; } +} diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..a97289f --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5231", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Repositories/OAuthClientRepository.cs b/Repositories/OAuthClientRepository.cs new file mode 100644 index 0000000..59ac748 --- /dev/null +++ b/Repositories/OAuthClientRepository.cs @@ -0,0 +1,107 @@ +using Fengling.AuthService.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fengling.Console.Repositories; + +public interface IOAuthClientRepository +{ + Task GetByIdAsync(long id); + Task GetByClientIdAsync(string clientId); + Task> GetAllAsync(); + Task> GetPagedAsync(int page, int pageSize, string? displayName = null, string? clientId = null, string? status = null); + Task CountAsync(string? displayName = null, string? clientId = null, string? status = null); + Task AddAsync(OAuthApplication client); + Task UpdateAsync(OAuthApplication client); + Task DeleteAsync(OAuthApplication client); +} + +public class OAuthClientRepository : IOAuthClientRepository +{ + private readonly Fengling.AuthService.Data.ApplicationDbContext _context; + + public OAuthClientRepository(Fengling.AuthService.Data.ApplicationDbContext context) + { + _context = context; + } + + public async Task GetByIdAsync(long id) + { + return await _context.OAuthApplications.FindAsync(id); + } + + public async Task GetByClientIdAsync(string clientId) + { + return await _context.OAuthApplications.FirstOrDefaultAsync(c => c.ClientId == clientId); + } + + public async Task> GetAllAsync() + { + return await _context.OAuthApplications.ToListAsync(); + } + + public async Task> GetPagedAsync(int page, int pageSize, string? displayName = null, string? clientId = null, string? status = null) + { + var query = _context.OAuthApplications.AsQueryable(); + + if (!string.IsNullOrEmpty(displayName)) + { + query = query.Where(c => c.DisplayName.Contains(displayName)); + } + + if (!string.IsNullOrEmpty(clientId)) + { + query = query.Where(c => c.ClientId.Contains(clientId)); + } + + if (!string.IsNullOrEmpty(status)) + { + query = query.Where(c => c.Status == status); + } + + return await query + .OrderByDescending(c => c.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + } + + public async Task CountAsync(string? displayName = null, string? clientId = null, string? status = null) + { + var query = _context.OAuthApplications.AsQueryable(); + + if (!string.IsNullOrEmpty(displayName)) + { + query = query.Where(c => c.DisplayName.Contains(displayName)); + } + + if (!string.IsNullOrEmpty(clientId)) + { + query = query.Where(c => c.ClientId.Contains(clientId)); + } + + if (!string.IsNullOrEmpty(status)) + { + query = query.Where(c => c.Status == status); + } + + return await query.CountAsync(); + } + + public async Task AddAsync(OAuthApplication client) + { + _context.OAuthApplications.Add(client); + await _context.SaveChangesAsync(); + } + + public async Task UpdateAsync(OAuthApplication client) + { + _context.OAuthApplications.Update(client); + await _context.SaveChangesAsync(); + } + + public async Task DeleteAsync(OAuthApplication client) + { + _context.OAuthApplications.Remove(client); + await _context.SaveChangesAsync(); + } +} diff --git a/Services/OAuthClientService.cs b/Services/OAuthClientService.cs new file mode 100644 index 0000000..0e33400 --- /dev/null +++ b/Services/OAuthClientService.cs @@ -0,0 +1,293 @@ +using Fengling.AuthService.Models; +using Fengling.Console.Repositories; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using System.Security.Cryptography; + +namespace Fengling.Console.Services; + +public interface IOAuthClientService +{ + Task<(IEnumerable Items, int TotalCount)> GetClientsAsync(int page, int pageSize, string? displayName = null, string? clientId = null, string? status = null); + Task GetClientAsync(long id); + Task CreateClientAsync(OAuthApplication client); + Task GenerateSecretAsync(long id); + Task ToggleStatusAsync(long id); + Task UpdateClientAsync(long id, OAuthApplication updatedClient); + Task DeleteClientAsync(long id); + object GetClientOptions(); +} + +public class OAuthClientService : IOAuthClientService +{ + private readonly IOAuthClientRepository _repository; + private readonly IOpenIddictApplicationManager _applicationManager; + private readonly ILogger _logger; + + public OAuthClientService( + IOAuthClientRepository repository, + IOpenIddictApplicationManager applicationManager, + ILogger logger) + { + _repository = repository; + _applicationManager = applicationManager; + _logger = logger; + } + + public async Task<(IEnumerable Items, int TotalCount)> GetClientsAsync(int page, int pageSize, string? displayName = null, string? clientId = null, string? status = null) + { + var items = await _repository.GetPagedAsync(page, pageSize, displayName, clientId, status); + var totalCount = await _repository.CountAsync(displayName, clientId, status); + return (items, totalCount); + } + + public async Task GetClientAsync(long id) + { + return await _repository.GetByIdAsync(id); + } + + public async Task CreateClientAsync(OAuthApplication client) + { + var existing = await _repository.GetByClientIdAsync(client.ClientId); + if (existing != null) + { + throw new InvalidOperationException($"Client ID '{client.ClientId}' 已存在"); + } + + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = client.ClientId, + ClientSecret = client.ClientSecret, + DisplayName = client.DisplayName + }; + + foreach (var uri in client.RedirectUris.Where(u => Uri.TryCreate(u, UriKind.Absolute, out _))) + { + descriptor.RedirectUris.Add(new Uri(uri)); + } + + foreach (var uri in client.PostLogoutRedirectUris.Where(u => Uri.TryCreate(u, UriKind.Absolute, out _))) + { + descriptor.PostLogoutRedirectUris.Add(new Uri(uri)); + } + + foreach (var grantType in client.GrantTypes) + { + descriptor.Permissions.Add(grantType); + } + + foreach (var scope in client.Scopes) + { + descriptor.Permissions.Add(scope); + } + + await _applicationManager.CreateAsync(descriptor); + await _repository.AddAsync(client); + + _logger.LogInformation("Created OAuth client {ClientId}", client.ClientId); + + return client; + } + + public async Task GenerateSecretAsync(long id) + { + var client = await _repository.GetByIdAsync(id); + if (client == null) + { + throw new KeyNotFoundException($"Client with ID {id} not found"); + } + + var application = await _applicationManager.FindByClientIdAsync(client.ClientId); + if (application == null) + { + throw new KeyNotFoundException($"OpenIddict application for client {client.ClientId} not found"); + } + + var newSecret = GenerateSecureSecret(); + + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = client.ClientId, + ClientSecret = newSecret, + DisplayName = client.DisplayName + }; + + foreach (var uri in client.RedirectUris.Where(u => Uri.TryCreate(u, UriKind.Absolute, out _))) + { + descriptor.RedirectUris.Add(new Uri(uri)); + } + + foreach (var uri in client.PostLogoutRedirectUris.Where(u => Uri.TryCreate(u, UriKind.Absolute, out _))) + { + descriptor.PostLogoutRedirectUris.Add(new Uri(uri)); + } + + foreach (var grantType in client.GrantTypes) + { + descriptor.Permissions.Add(grantType); + } + + foreach (var scope in client.Scopes) + { + descriptor.Permissions.Add(scope); + } + + await _applicationManager.UpdateAsync(application, descriptor); + + client.ClientSecret = newSecret; + client.UpdatedAt = DateTime.UtcNow; + await _repository.UpdateAsync(client); + + _logger.LogInformation("Generated new secret for OAuth client {ClientId}", client.ClientId); + + return client; + } + + public async Task ToggleStatusAsync(long id) + { + var client = await _repository.GetByIdAsync(id); + if (client == null) + { + throw new KeyNotFoundException($"Client with ID {id} not found"); + } + + var oldStatus = client.Status; + client.Status = client.Status == "active" ? "inactive" : "active"; + client.UpdatedAt = DateTime.UtcNow; + await _repository.UpdateAsync(client); + + _logger.LogInformation("Toggled OAuth client {ClientId} status from {OldStatus} to {NewStatus}", client.ClientId, oldStatus, client.Status); + + return client; + } + + public async Task UpdateClientAsync(long id, OAuthApplication updatedClient) + { + var client = await _repository.GetByIdAsync(id); + if (client == null) + { + throw new KeyNotFoundException($"Client with ID {id} not found"); + } + + var application = await _applicationManager.FindByClientIdAsync(client.ClientId); + if (application == null) + { + throw new KeyNotFoundException($"OpenIddict application for client {client.ClientId} not found"); + } + + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = client.ClientId, + ClientSecret = client.ClientSecret, + DisplayName = updatedClient.DisplayName ?? client.DisplayName + }; + + var redirectUris = updatedClient.RedirectUris ?? client.RedirectUris; + foreach (var uri in redirectUris.Where(u => Uri.TryCreate(u, UriKind.Absolute, out _))) + { + descriptor.RedirectUris.Add(new Uri(uri)); + } + + var postLogoutUris = updatedClient.PostLogoutRedirectUris ?? client.PostLogoutRedirectUris; + foreach (var uri in postLogoutUris.Where(u => Uri.TryCreate(u, UriKind.Absolute, out _))) + { + descriptor.PostLogoutRedirectUris.Add(new Uri(uri)); + } + + var grantTypes = updatedClient.GrantTypes ?? client.GrantTypes; + foreach (var grantType in grantTypes) + { + descriptor.Permissions.Add(grantType); + } + + var scopes = updatedClient.Scopes ?? client.Scopes; + foreach (var scope in scopes) + { + descriptor.Permissions.Add(scope); + } + + await _applicationManager.UpdateAsync(application, descriptor); + + client.DisplayName = updatedClient.DisplayName ?? client.DisplayName; + client.RedirectUris = redirectUris; + client.PostLogoutRedirectUris = postLogoutUris; + client.Scopes = scopes; + client.GrantTypes = grantTypes; + client.ClientType = updatedClient.ClientType ?? client.ClientType; + client.ConsentType = updatedClient.ConsentType ?? client.ConsentType; + client.Status = updatedClient.Status ?? client.Status; + client.Description = updatedClient.Description ?? client.Description; + client.UpdatedAt = DateTime.UtcNow; + await _repository.UpdateAsync(client); + + _logger.LogInformation("Updated OAuth client {ClientId}", client.ClientId); + + return client; + } + + public async Task DeleteClientAsync(long id) + { + var client = await _repository.GetByIdAsync(id); + if (client == null) + { + throw new KeyNotFoundException($"Client with ID {id} not found"); + } + + var application = await _applicationManager.FindByClientIdAsync(client.ClientId); + if (application != null) + { + await _applicationManager.DeleteAsync(application); + _logger.LogInformation("Deleted OpenIddict application for client {ClientId}", client.ClientId); + } + + await _repository.DeleteAsync(client); + + _logger.LogInformation("Deleted OAuth client {ClientId}", client.ClientId); + } + + public object GetClientOptions() + { + return new + { + clientTypes = new[] + { + new { value = "public", label = "Public (SPA, Mobile App)" }, + new { value = "confidential", label = "Confidential (Server-side)" } + }, + consentTypes = new[] + { + new { value = "implicit", label = "Implicit" }, + new { value = "explicit", label = "Explicit" }, + new { value = "system", label = "System (Pre-authorized)" } + }, + grantTypes = new[] + { + new { value = "authorization_code", label = "Authorization Code" }, + new { value = "client_credentials", label = "Client Credentials" }, + new { value = "refresh_token", label = "Refresh Token" }, + new { value = "password", label = "Resource Owner Password Credentials" } + }, + scopes = new[] + { + new { value = "openid", label = "OpenID Connect" }, + new { value = "profile", label = "Profile" }, + new { value = "email", label = "Email" }, + new { value = "api", label = "API Access" }, + new { value = "offline_access", label = "Offline Access" } + }, + statuses = new[] + { + new { value = "active", label = "Active" }, + new { value = "inactive", label = "Inactive" }, + new { value = "suspended", label = "Suspended" } + } + }; + } + + private static string GenerateSecureSecret(int length = 32) + { + var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + var bytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(length); + return new string(bytes.Select(b => chars[b % chars.Length]).ToArray()); + } +} diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}