feat(console): complete migration of User, Tenant, and Role management APIs

This commit is contained in:
Sam 2026-02-05 14:21:36 +08:00
parent 4d7abd6fdb
commit e1ba3a90c3
8 changed files with 754 additions and 0 deletions

View File

@ -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<OAuthClientsController> _logger;
public OAuthClientsController(
IOAuthClientService service,
ILogger<OAuthClientsController> logger)
{
_service = service;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<object>> 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<object> GetClientOptions()
{
return Ok(_service.GetClientOptions());
}
[HttpGet("{id}")]
public async Task<ActionResult<OAuthClientDto>> 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<ActionResult<OAuthClientDto>> 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<string>(),
PostLogoutRedirectUris = dto.PostLogoutRedirectUris ?? Array.Empty<string>(),
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<ActionResult> 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<ActionResult> 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<IActionResult> 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<IActionResult> 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());
}
}

6
Fengling.Console.http Normal file
View File

@ -0,0 +1,6 @@
@Fengling.Console_HostAddress = http://localhost:5299
GET {{Fengling.Console_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -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<string>();
public string[] PostLogoutRedirectUris { get; init; } = Array.Empty<string>();
public string[] Scopes { get; init; } = Array.Empty<string>();
public string[] GrantTypes { get; init; } = Array.Empty<string>();
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; }
}

View File

@ -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"
}
}
}
}

View File

@ -0,0 +1,107 @@
using Fengling.AuthService.Models;
using Microsoft.EntityFrameworkCore;
namespace Fengling.Console.Repositories;
public interface IOAuthClientRepository
{
Task<OAuthApplication?> GetByIdAsync(long id);
Task<OAuthApplication?> GetByClientIdAsync(string clientId);
Task<IEnumerable<OAuthApplication>> GetAllAsync();
Task<IEnumerable<OAuthApplication>> GetPagedAsync(int page, int pageSize, string? displayName = null, string? clientId = null, string? status = null);
Task<int> 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<OAuthApplication?> GetByIdAsync(long id)
{
return await _context.OAuthApplications.FindAsync(id);
}
public async Task<OAuthApplication?> GetByClientIdAsync(string clientId)
{
return await _context.OAuthApplications.FirstOrDefaultAsync(c => c.ClientId == clientId);
}
public async Task<IEnumerable<OAuthApplication>> GetAllAsync()
{
return await _context.OAuthApplications.ToListAsync();
}
public async Task<IEnumerable<OAuthApplication>> 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<int> 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();
}
}

View File

@ -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<OAuthApplication> Items, int TotalCount)> GetClientsAsync(int page, int pageSize, string? displayName = null, string? clientId = null, string? status = null);
Task<OAuthApplication?> GetClientAsync(long id);
Task<OAuthApplication> CreateClientAsync(OAuthApplication client);
Task<OAuthApplication> GenerateSecretAsync(long id);
Task<OAuthApplication> ToggleStatusAsync(long id);
Task<OAuthApplication> 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<OAuthClientService> _logger;
public OAuthClientService(
IOAuthClientRepository repository,
IOpenIddictApplicationManager applicationManager,
ILogger<OAuthClientService> logger)
{
_repository = repository;
_applicationManager = applicationManager;
_logger = logger;
}
public async Task<(IEnumerable<OAuthApplication> 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<OAuthApplication?> GetClientAsync(long id)
{
return await _repository.GetByIdAsync(id);
}
public async Task<OAuthApplication> 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<OAuthApplication> 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<OAuthApplication> 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<OAuthApplication> 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());
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

9
appsettings.json Normal file
View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}