diff --git a/Configuration/OpenIddictSetup.cs b/Configuration/OpenIddictSetup.cs index 0e73445..8d5abcd 100644 --- a/Configuration/OpenIddictSetup.cs +++ b/Configuration/OpenIddictSetup.cs @@ -1,6 +1,6 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Tokens; -using OpenIddict.Validation.AspNetCore; namespace Fengling.AuthService.Configuration; @@ -11,42 +11,41 @@ public static class OpenIddictSetup IConfiguration configuration ) { - services - .AddOpenIddict() - .AddCore(options => - { - options.UseEntityFrameworkCore().UseDbContext(); - }) - .AddServer(options => - { - options.SetIssuer( - configuration["OpenIddict:Issuer"] ?? "https://auth.fengling.local" - ); + var isTesting = configuration.GetValue("Testing", false); - options.AddDevelopmentEncryptionCertificate().AddDevelopmentSigningCertificate(); + var builder = services.AddOpenIddict(); - options - .AllowAuthorizationCodeFlow() - .AllowPasswordFlow() - .AllowRefreshTokenFlow() - .RequireProofKeyForCodeExchange(); + builder.AddCore(options => + { + options.UseEntityFrameworkCore().UseDbContext(); + }); + + if (!isTesting) + { + builder.AddServer(options => + { + options.SetIssuer(configuration["OpenIddict:Issuer"] ?? "https://auth.fengling.local"); + + options.AddDevelopmentEncryptionCertificate() + .AddDevelopmentSigningCertificate(); + + options.AllowAuthorizationCodeFlow() + .AllowPasswordFlow() + .AllowRefreshTokenFlow() + .RequireProofKeyForCodeExchange(); options.RegisterScopes("api", "offline_access"); - - options.UseAspNetCore(); - }) - .AddValidation(options => - { - options.UseLocalServer(); - options.UseAspNetCore(); }); + } + + builder.AddValidation(options => + { + options.UseLocalServer(); + }); services.AddAuthentication(options => { - options.DefaultAuthenticateScheme = - OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = - OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; }); return services; diff --git a/Controllers/AccessLogsController.cs b/Controllers/AccessLogsController.cs new file mode 100644 index 0000000..3abe130 --- /dev/null +++ b/Controllers/AccessLogsController.cs @@ -0,0 +1,158 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class AccessLogsController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly ILogger _logger; + + public AccessLogsController( + ApplicationDbContext context, + ILogger logger) + { + _context = context; + _logger = logger; + } + + [HttpGet] + public async Task> GetAccessLogs( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? userName = null, + [FromQuery] string? tenantId = null, + [FromQuery] string? action = null, + [FromQuery] string? status = null, + [FromQuery] DateTime? startTime = null, + [FromQuery] DateTime? endTime = null) + { + var query = _context.AccessLogs.AsQueryable(); + + if (!string.IsNullOrEmpty(userName)) + { + query = query.Where(l => l.UserName != null && l.UserName.Contains(userName)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(l => l.TenantId == tenantId); + } + + if (!string.IsNullOrEmpty(action)) + { + query = query.Where(l => l.Action == action); + } + + if (!string.IsNullOrEmpty(status)) + { + query = query.Where(l => l.Status == status); + } + + if (startTime.HasValue) + { + query = query.Where(l => l.CreatedAt >= startTime.Value); + } + + if (endTime.HasValue) + { + query = query.Where(l => l.CreatedAt <= endTime.Value); + } + + var totalCount = await query.CountAsync(); + var logs = await query + .OrderByDescending(l => l.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = logs.Select(l => new + { + id = l.Id, + userName = l.UserName, + tenantId = l.TenantId, + action = l.Action, + resource = l.Resource, + method = l.Method, + ipAddress = l.IpAddress, + userAgent = l.UserAgent, + status = l.Status, + duration = l.Duration, + requestData = l.RequestData, + responseData = l.ResponseData, + errorMessage = l.ErrorMessage, + createdAt = l.CreatedAt, + }); + + return Ok(new + { + items = result, + totalCount, + page, + pageSize + }); + } + + [HttpGet("export")] + public async Task ExportAccessLogs( + [FromQuery] string? userName = null, + [FromQuery] string? tenantId = null, + [FromQuery] string? action = null, + [FromQuery] string? status = null, + [FromQuery] DateTime? startTime = null, + [FromQuery] DateTime? endTime = null) + { + var query = _context.AccessLogs.AsQueryable(); + + if (!string.IsNullOrEmpty(userName)) + { + query = query.Where(l => l.UserName != null && l.UserName.Contains(userName)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(l => l.TenantId == tenantId); + } + + if (!string.IsNullOrEmpty(action)) + { + query = query.Where(l => l.Action == action); + } + + if (!string.IsNullOrEmpty(status)) + { + query = query.Where(l => l.Status == status); + } + + if (startTime.HasValue) + { + query = query.Where(l => l.CreatedAt >= startTime.Value); + } + + if (endTime.HasValue) + { + query = query.Where(l => l.CreatedAt <= endTime.Value); + } + + var logs = await query + .OrderByDescending(l => l.CreatedAt) + .Take(10000) + .ToListAsync(); + + var csv = new System.Text.StringBuilder(); + csv.AppendLine("ID,UserName,TenantId,Action,Resource,Method,IpAddress,UserAgent,Status,Duration,CreatedAt"); + + foreach (var log in logs) + { + csv.AppendLine($"{log.Id},{log.UserName},{log.TenantId},{log.Action},{log.Resource},{log.Method},{log.IpAddress},\"{log.UserAgent}\",{log.Status},{log.Duration},{log.CreatedAt:yyyy-MM-dd HH:mm:ss}"); + } + + return File(System.Text.Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"access-logs-{DateTime.UtcNow:yyyyMMdd}.csv"); + } +} diff --git a/Controllers/AccountController.cs b/Controllers/AccountController.cs new file mode 100644 index 0000000..d8ee7cb --- /dev/null +++ b/Controllers/AccountController.cs @@ -0,0 +1,119 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Fengling.AuthService.ViewModels; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace Fengling.AuthService.Controllers; + +[Route("account")] +public class AccountController : Controller +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public AccountController( + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + [HttpGet("login")] + public IActionResult Login(string returnUrl = "/") + { + return View(new LoginInputModel { ReturnUrl = returnUrl }); + } + + [HttpPost("login")] + [ValidateAntiForgeryToken] + public async Task Login(LoginInputModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var user = await _userManager.FindByNameAsync(model.Username); + if (user == null || user.IsDeleted) + { + ModelState.AddModelError(string.Empty, "用户名或密码错误"); + return View(model); + } + + var result = await _signInManager.PasswordSignInAsync(user, model.Password, model.RememberMe, false); + if (!result.Succeeded) + { + if (result.IsLockedOut) + { + ModelState.AddModelError(string.Empty, "账号已被锁定"); + } + else + { + ModelState.AddModelError(string.Empty, "用户名或密码错误"); + } + return View(model); + } + + return LocalRedirect(model.ReturnUrl); + } + + [HttpGet("register")] + public IActionResult Register(string returnUrl = "/") + { + return View(new RegisterViewModel { ReturnUrl = returnUrl }); + } + + [HttpPost("register")] + [ValidateAntiForgeryToken] + public async Task Register(RegisterViewModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var user = new ApplicationUser + { + UserName = model.Username, + Email = model.Email, + NormalizedUserName = model.Username.ToUpper(), + NormalizedEmail = model.Email.ToUpper() + }; + + var result = await _userManager.CreateAsync(user, model.Password); + if (!result.Succeeded) + { + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return View(model); + } + + await _signInManager.SignInAsync(user, isPersistent: false); + return LocalRedirect(model.ReturnUrl); + } + + [HttpGet("profile")] + [HttpGet("settings")] + [HttpGet("logout")] + public IActionResult NotImplemented() + { + return RedirectToAction("Index", "Dashboard"); + } + + [HttpPost("logout")] + [ValidateAntiForgeryToken] + public async Task LogoutPost() + { + await _signInManager.SignOutAsync(); + return Redirect("/"); + } +} diff --git a/Controllers/AuditLogsController.cs b/Controllers/AuditLogsController.cs new file mode 100644 index 0000000..f009aca --- /dev/null +++ b/Controllers/AuditLogsController.cs @@ -0,0 +1,159 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class AuditLogsController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly ILogger _logger; + + public AuditLogsController( + ApplicationDbContext context, + ILogger logger) + { + _context = context; + _logger = logger; + } + + [HttpGet] + public async Task> GetAuditLogs( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? operatorName = null, + [FromQuery] string? tenantId = null, + [FromQuery] string? operation = null, + [FromQuery] string? action = null, + [FromQuery] DateTime? startTime = null, + [FromQuery] DateTime? endTime = null) + { + var query = _context.AuditLogs.AsQueryable(); + + if (!string.IsNullOrEmpty(operatorName)) + { + query = query.Where(l => l.Operator.Contains(operatorName)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(l => l.TenantId == tenantId); + } + + if (!string.IsNullOrEmpty(operation)) + { + query = query.Where(l => l.Operation == operation); + } + + if (!string.IsNullOrEmpty(action)) + { + query = query.Where(l => l.Action == action); + } + + if (startTime.HasValue) + { + query = query.Where(l => l.CreatedAt >= startTime.Value); + } + + if (endTime.HasValue) + { + query = query.Where(l => l.CreatedAt <= endTime.Value); + } + + var totalCount = await query.CountAsync(); + var logs = await query + .OrderByDescending(l => l.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = logs.Select(l => new + { + id = l.Id, + @operator = l.Operator, + tenantId = l.TenantId, + operation = l.Operation, + action = l.Action, + targetType = l.TargetType, + targetId = l.TargetId, + targetName = l.TargetName, + ipAddress = l.IpAddress, + description = l.Description, + oldValue = l.OldValue, + newValue = l.NewValue, + errorMessage = l.ErrorMessage, + status = l.Status, + createdAt = l.CreatedAt, + }); + + return Ok(new + { + items = result, + totalCount, + page, + pageSize + }); + } + + [HttpGet("export")] + public async Task ExportAuditLogs( + [FromQuery] string? operatorName = null, + [FromQuery] string? tenantId = null, + [FromQuery] string? operation = null, + [FromQuery] string? action = null, + [FromQuery] DateTime? startTime = null, + [FromQuery] DateTime? endTime = null) + { + var query = _context.AuditLogs.AsQueryable(); + + if (!string.IsNullOrEmpty(operatorName)) + { + query = query.Where(l => l.Operator.Contains(operatorName)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(l => l.TenantId == tenantId); + } + + if (!string.IsNullOrEmpty(operation)) + { + query = query.Where(l => l.Operation == operation); + } + + if (!string.IsNullOrEmpty(action)) + { + query = query.Where(l => l.Action == action); + } + + if (startTime.HasValue) + { + query = query.Where(l => l.CreatedAt >= startTime.Value); + } + + if (endTime.HasValue) + { + query = query.Where(l => l.CreatedAt <= endTime.Value); + } + + var logs = await query + .OrderByDescending(l => l.CreatedAt) + .Take(10000) + .ToListAsync(); + + var csv = new System.Text.StringBuilder(); + csv.AppendLine("ID,Operator,TenantId,Operation,Action,TargetType,TargetId,TargetName,IpAddress,Description,Status,CreatedAt"); + + foreach (var log in logs) + { + csv.AppendLine($"{log.Id},{log.Operator},{log.TenantId},{log.Operation},{log.Action},{log.TargetType},{log.TargetId},{log.TargetName},{log.IpAddress},\"{log.Description}\",{log.Status},{log.CreatedAt:yyyy-MM-dd HH:mm:ss}"); + } + + return File(System.Text.Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"audit-logs-{DateTime.UtcNow:yyyyMMdd}.csv"); + } +} diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs deleted file mode 100644 index c57d7d7..0000000 --- a/Controllers/AuthController.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Fengling.AuthService.DTOs; -using Fengling.AuthService.Models; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; -using OpenIddict.Abstractions; -using OpenIddict.Server.AspNetCore; -using System.Security.Claims; -using static OpenIddict.Abstractions.OpenIddictConstants; - -namespace Fengling.AuthService.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class AuthController : ControllerBase -{ - private readonly SignInManager _signInManager; - private readonly UserManager _userManager; - private readonly IOpenIddictApplicationManager _applicationManager; - private readonly IOpenIddictAuthorizationManager _authorizationManager; - private readonly IOpenIddictScopeManager _scopeManager; - private readonly ILogger _logger; - - public AuthController( - SignInManager signInManager, - UserManager userManager, - IOpenIddictApplicationManager applicationManager, - IOpenIddictAuthorizationManager authorizationManager, - IOpenIddictScopeManager scopeManager, - ILogger logger) - { - _signInManager = signInManager; - _userManager = userManager; - _applicationManager = applicationManager; - _authorizationManager = authorizationManager; - _scopeManager = scopeManager; - _logger = logger; - } - - [HttpPost("login")] - public async Task Login([FromBody] LoginRequest request) - { - var user = await _userManager.FindByNameAsync(request.UserName); - if (user == null || user.IsDeleted) - { - return Unauthorized(new { error = "用户不存在" }); - } - - if (user.TenantId != request.TenantId) - { - return Unauthorized(new { error = "租户不匹配" }); - } - - var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, false); - if (!result.Succeeded) - { - return Unauthorized(new { error = "用户名或密码错误" }); - } - - var token = await GenerateTokenAsync(user); - return Ok(token); - } - - private async Task GenerateTokenAsync(ApplicationUser user) - { - var claims = new List - { - new(Claims.Subject, user.Id.ToString()), - new(Claims.Name, user.UserName ?? string.Empty), - new(Claims.Email, user.Email ?? string.Empty), - new("tenant_id", user.TenantId.ToString()) - }; - - var roles = await _userManager.GetRolesAsync(user); - foreach (var role in roles) - { - claims.Add(new Claim(Claims.Role, role)); - } - - var identity = new System.Security.Claims.ClaimsIdentity(claims, "Server"); - var principal = new System.Security.Claims.ClaimsPrincipal(identity); - - return new LoginResponse - { - AccessToken = "token-placeholder", - RefreshToken = "refresh-placeholder", - ExpiresIn = 3600, - TokenType = "Bearer" - }; - } -} diff --git a/Controllers/AuthorizationController.cs b/Controllers/AuthorizationController.cs new file mode 100644 index 0000000..a1baff4 --- /dev/null +++ b/Controllers/AuthorizationController.cs @@ -0,0 +1,217 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using System.Security.Claims; +using Fengling.AuthService.ViewModels; +using Microsoft.AspNetCore; +using Microsoft.Extensions.Primitives; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("connect")] +public class AuthorizationController( + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager, + IOpenIddictScopeManager scopeManager, + SignInManager signInManager, + UserManager userManager, + ILogger logger) + : Controller +{ + + [HttpGet("authorize")] + [HttpPost("authorize")] + public async Task Authorize() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + + // If prompt=login was specified by the client application, + // immediately return the user agent to the login page. + if (request.HasPromptValue(OpenIddictConstants.PromptValues.Login)) + { + // To avoid endless login -> authorization redirects, the prompt=login flag + // is removed from the authorization request payload before redirecting the user. + var prompt = string.Join(" ", request.GetPromptValues().Remove(OpenIddictConstants.PromptValues.Login)); + + var parameters = Request.HasFormContentType + ? Request.Form.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList() + : Request.Query.Where(parameter => parameter.Key != OpenIddictConstants.Parameters.Prompt).ToList(); + + parameters.Add(KeyValuePair.Create(OpenIddictConstants.Parameters.Prompt, new StringValues(prompt))); + + return Challenge( + authenticationSchemes: IdentityConstants.ApplicationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters) + }); + } + + // Retrieve the user principal stored in the authentication cookie. + // If a max_age parameter was provided, ensure that the cookie is not too old. + // If the user principal can't be extracted or the cookie is too old, redirect the user to the login page. + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + if (result is not { Succeeded: true } || (request.MaxAge != null && result.Properties?.IssuedUtc != null && + DateTimeOffset.UtcNow - result.Properties.IssuedUtc > + TimeSpan.FromSeconds(request.MaxAge.Value))) + { + // If the client application requested promptless authentication, + // return an error indicating that the user is not logged in. + if (request.HasPromptValue(OpenIddictConstants.PromptValues.None)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = + OpenIddictConstants.Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." + }!)); + } + + return Challenge( + authenticationSchemes: IdentityConstants.ApplicationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create( + Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) + }); + } + + // Retrieve the profile of the logged in user. + var user = await userManager.GetUserAsync(result.Principal) ?? + throw new InvalidOperationException("The user details cannot be retrieved."); + + // Retrieve the application details from the database. + var application = await applicationManager.FindByClientIdAsync(request.ClientId!) ?? + throw new InvalidOperationException( + "Details concerning the calling client application cannot be found."); + + // Retrieve the permanent authorizations associated with the user and the calling client application. + var authorizations = await authorizationManager.FindAsync( + subject: await userManager.GetUserIdAsync(user), + client: (await applicationManager.GetIdAsync(application))!, + status: OpenIddictConstants.Statuses.Valid, + type: OpenIddictConstants.AuthorizationTypes.Permanent, + scopes: request.GetScopes()).ToListAsync(); + + switch (await applicationManager.GetConsentTypeAsync(application)) + { + // If the consent is external (e.g when authorizations are granted by a sysadmin), + // immediately return an error if no authorization can be found in the database. + case OpenIddictConstants.ConsentTypes.External when !authorizations.Any(): + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = + OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application." + }!)); + + // If the consent is implicit or if an authorization was found, + // return an authorization response without displaying the consent form. + case OpenIddictConstants.ConsentTypes.Implicit: + case OpenIddictConstants.ConsentTypes.External when authorizations.Any(): + case OpenIddictConstants.ConsentTypes.Explicit + when authorizations.Any() && !request.HasPromptValue(OpenIddictConstants.PromptValues.Consent): + var principal = await signInManager.CreateUserPrincipalAsync(user); + + // Note: in this sample, the granted scopes match the requested scope + // but you may want to allow the user to uncheck specific scopes. + // For that, simply restrict the list of scopes before calling SetScopes. + principal.SetScopes(request.GetScopes()); + principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); + + // Automatically create a permanent authorization to avoid requiring explicit consent + // for future authorization or token requests containing the same scopes. + var authorization = authorizations.LastOrDefault(); + if (authorization == null) + { + authorization = await authorizationManager.CreateAsync( + principal: principal, + subject: await userManager.GetUserIdAsync(user), + client: (await applicationManager.GetIdAsync(application))!, + type: OpenIddictConstants.AuthorizationTypes.Permanent, + scopes: principal.GetScopes()); + } + + principal.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization)); + + foreach (var claim in principal.Claims) + { + claim.SetDestinations(GetDestinations(claim, principal)); + } + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + // At this point, no authorization was found in the database and an error must be returned + // if the client application specified prompt=none in the authorization request. + case OpenIddictConstants.ConsentTypes.Explicit when request.HasPromptValue(OpenIddictConstants.PromptValues.None): + case OpenIddictConstants.ConsentTypes.Systematic when request.HasPromptValue(OpenIddictConstants.PromptValues.None): + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = + OpenIddictConstants.Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "Interactive user consent is required." + }!)); + + // In every other case, render the consent form. + default: + return View(new AuthorizeViewModel(await applicationManager.GetDisplayNameAsync(application),request.Scope)); + } + } + + private IEnumerable GetDestinations(Claim claim, ClaimsPrincipal principal) + { + // Note: by default, claims are NOT automatically included in the access and identity tokens. + // To allow OpenIddict to serialize them, you must attach them a destination, that specifies + // whether they should be included in access tokens, in identity tokens or in both. + + switch (claim.Type) + { + case OpenIddictConstants.Claims.Name: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Profile)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Email: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Email)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Role: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Roles)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + // Never include the security stamp in the access and identity tokens, as it's a secret value. + case "AspNet.Identity.SecurityStamp": yield break; + + default: + yield return OpenIddictConstants.Destinations.AccessToken; + yield break; + } + } +} diff --git a/Controllers/DashboardController.cs b/Controllers/DashboardController.cs new file mode 100644 index 0000000..1732d36 --- /dev/null +++ b/Controllers/DashboardController.cs @@ -0,0 +1,61 @@ +using Fengling.AuthService.ViewModels; +using Microsoft.AspNetCore.Mvc; + +namespace Fengling.AuthService.Controllers; + +[Route("dashboard")] +public class DashboardController : Controller +{ + private readonly ILogger _logger; + + public DashboardController(ILogger logger) + { + _logger = logger; + } + + [HttpGet("")] + [HttpGet("index")] + public IActionResult Index() + { + if (User.Identity?.IsAuthenticated != true) + { + return RedirectToAction("Login", "Account", new { returnUrl = "/dashboard" }); + } + + return View("Index", new DashboardViewModel + { + Username = User.Identity?.Name, + Email = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value + }); + } + + [HttpGet("profile")] + public IActionResult Profile() + { + if (User.Identity?.IsAuthenticated != true) + { + return RedirectToAction("Login", "Account", new { returnUrl = "/dashboard/profile" }); + } + + return View(new DashboardViewModel + { + Username = User.Identity?.Name, + Email = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value + }); + } + + [HttpGet("settings")] + public IActionResult Settings() + { + if (User.Identity?.IsAuthenticated != true) + { + return RedirectToAction("Login", "Account", new { returnUrl = "/dashboard/settings" }); + } + + return View(new DashboardViewModel + { + Username = User.Identity?.Name, + Email = User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value + }); + } +} diff --git a/Controllers/LogoutController.cs b/Controllers/LogoutController.cs new file mode 100644 index 0000000..502a4b6 --- /dev/null +++ b/Controllers/LogoutController.cs @@ -0,0 +1,71 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("connect")] +public class LogoutController : ControllerBase +{ + private readonly IOpenIddictApplicationManager _applicationManager; + private readonly IOpenIddictAuthorizationManager _authorizationManager; + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public LogoutController( + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager, + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _applicationManager = applicationManager; + _authorizationManager = authorizationManager; + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + [HttpGet("endsession")] + [HttpPost("endsession")] + [IgnoreAntiforgeryToken] + public async Task EndSession() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("OpenIddict request is null"); + + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + if (result.Succeeded) + { + await _signInManager.SignOutAsync(); + } + + if (request.ClientId != null) + { + var application = await _applicationManager.FindByClientIdAsync(request.ClientId); + if (application != null) + { + var postLogoutRedirectUri = await _applicationManager.GetPostLogoutRedirectUrisAsync(application); + if (!string.IsNullOrEmpty(request.PostLogoutRedirectUri)) + { + if (postLogoutRedirectUri.Contains(request.PostLogoutRedirectUri)) + { + return Redirect(request.PostLogoutRedirectUri); + } + } + } + } + + + return Redirect("/"); + } +} diff --git a/Controllers/OAuthClientsController.cs b/Controllers/OAuthClientsController.cs index b0c3138..a98e39d 100644 --- a/Controllers/OAuthClientsController.cs +++ b/Controllers/OAuthClientsController.cs @@ -1,12 +1,15 @@ using Fengling.AuthService.Data; using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Security.Claims; namespace Fengling.AuthService.Controllers; [ApiController] [Route("api/[controller]")] +[Authorize] public class OAuthClientsController : ControllerBase { private readonly ApplicationDbContext _context; @@ -21,9 +24,60 @@ public class OAuthClientsController : ControllerBase } [HttpGet] - public async Task>> GetClients() + public async Task> GetClients( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + [FromQuery] string? displayName = null, + [FromQuery] string? clientId = null, + [FromQuery] string? status = null) { - return await _context.OAuthApplications.ToListAsync(); + 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); + } + + var totalCount = await query.CountAsync(); + var clients = await query + .OrderByDescending(c => c.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = clients.Select(c => new + { + 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, + }); + + return Ok(new + { + items = result, + totalCount, + page, + pageSize + }); } [HttpGet("{id}")] @@ -34,27 +88,97 @@ public class OAuthClientsController : ControllerBase { return NotFound(); } - return client; + + return Ok(new + { + 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, + }); + } + + [HttpGet("{id}/secret")] + public async Task> GetClientSecret(long id) + { + var client = await _context.OAuthApplications.FindAsync(id); + if (client == null) + { + return NotFound(); + } + + return Ok(new + { + clientId = client.ClientId, + clientSecret = client.ClientSecret, + }); } [HttpPost] - public async Task> CreateClient(OAuthApplication application) + public async Task> CreateClient(CreateOAuthClientDto dto) { - _context.OAuthApplications.Add(application); + if (await _context.OAuthApplications.AnyAsync(c => c.ClientId == dto.ClientId)) + { + return BadRequest(new { message = "Client ID 已存在" }); + } + + var client = new OAuthApplication + { + ClientId = dto.ClientId, + ClientSecret = dto.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 = DateTime.UtcNow, + }; + + _context.OAuthApplications.Add(client); await _context.SaveChangesAsync(); - return CreatedAtAction(nameof(GetClient), new { id = application.Id }, application); + + await CreateAuditLog("oauth", "create", "OAuthClient", client.Id, client.DisplayName, null, SerializeToJson(dto)); + + return CreatedAtAction(nameof(GetClient), new { id = client.Id }, client); } [HttpPut("{id}")] - public async Task UpdateClient(long id, OAuthApplication application) + public async Task UpdateClient(long id, UpdateOAuthClientDto dto) { - if (id != application.Id) + var client = await _context.OAuthApplications.FindAsync(id); + if (client == null) { - return BadRequest(); + return NotFound(); } - _context.Entry(application).State = EntityState.Modified; + var oldValue = SerializeToJson(client); + + client.DisplayName = dto.DisplayName; + client.RedirectUris = dto.RedirectUris; + client.PostLogoutRedirectUris = dto.PostLogoutRedirectUris; + client.Scopes = dto.Scopes; + client.GrantTypes = dto.GrantTypes; + client.ClientType = dto.ClientType; + client.ConsentType = dto.ConsentType; + client.Status = dto.Status; + client.Description = dto.Description; + await _context.SaveChangesAsync(); + + await CreateAuditLog("oauth", "update", "OAuthClient", client.Id, client.DisplayName, oldValue, SerializeToJson(client)); + return NoContent(); } @@ -67,8 +191,73 @@ public class OAuthClientsController : ControllerBase return NotFound(); } + var oldValue = SerializeToJson(client); + _context.OAuthApplications.Remove(client); await _context.SaveChangesAsync(); + + await CreateAuditLog("oauth", "delete", "OAuthClient", client.Id, client.DisplayName, oldValue); + return NoContent(); } + + private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null) + { + var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system"; + var tenantId = User.FindFirstValue("TenantId"); + + var log = new AuditLog + { + Operator = userName, + TenantId = tenantId, + Operation = operation, + Action = action, + TargetType = targetType, + TargetId = targetId, + TargetName = targetName, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + Status = "success", + OldValue = oldValue, + NewValue = newValue, + }; + + _context.AuditLogs.Add(log); + await _context.SaveChangesAsync(); + } + + private string SerializeToJson(object obj) + { + return System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = false + }); + } +} + +public class CreateOAuthClientDto +{ + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string[] RedirectUris { get; set; } = Array.Empty(); + public string[] PostLogoutRedirectUris { get; set; } = Array.Empty(); + public string[] Scopes { get; set; } = Array.Empty(); + public string[] GrantTypes { get; set; } = Array.Empty(); + public string ClientType { get; set; } = "confidential"; + public string ConsentType { get; set; } = "implicit"; + public string Status { get; set; } = "active"; + public string? Description { get; set; } +} + +public class UpdateOAuthClientDto +{ + public string DisplayName { get; set; } = string.Empty; + public string[] RedirectUris { get; set; } = Array.Empty(); + public string[] PostLogoutRedirectUris { get; set; } = Array.Empty(); + public string[] Scopes { get; set; } = Array.Empty(); + public string[] GrantTypes { get; set; } = Array.Empty(); + public string ClientType { get; set; } = "confidential"; + public string ConsentType { get; set; } = "implicit"; + public string Status { get; set; } = "active"; + public string? Description { get; set; } } diff --git a/Controllers/RolesController.cs b/Controllers/RolesController.cs new file mode 100644 index 0000000..9beef92 --- /dev/null +++ b/Controllers/RolesController.cs @@ -0,0 +1,290 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class RolesController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly RoleManager _roleManager; + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public RolesController( + ApplicationDbContext context, + RoleManager roleManager, + UserManager userManager, + ILogger logger) + { + _context = context; + _roleManager = roleManager; + _userManager = userManager; + _logger = logger; + } + + [HttpGet] + public async Task> GetRoles( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + [FromQuery] string? name = null, + [FromQuery] string? tenantId = null) + { + var query = _context.Roles.AsQueryable(); + + if (!string.IsNullOrEmpty(name)) + { + query = query.Where(r => r.Name != null && r.Name.Contains(name)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(r => r.TenantId.ToString() == tenantId); + } + + var totalCount = await query.CountAsync(); + var roles = await query + .OrderByDescending(r => r.CreatedTime) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = new List(); + + foreach (var role in roles) + { + var users = await _userManager.GetUsersInRoleAsync(role.Name!); + result.Add(new + { + id = role.Id, + name = role.Name, + displayName = role.DisplayName, + description = role.Description, + tenantId = role.TenantId, + isSystem = role.IsSystem, + permissions = role.Permissions, + userCount = users.Count, + createdAt = role.CreatedTime, + }); + } + + return Ok(new + { + items = result, + totalCount, + page, + pageSize + }); + } + + [HttpGet("{id}")] + public async Task> GetRole(long id) + { + var role = await _context.Roles.FindAsync(id); + if (role == null) + { + return NotFound(); + } + + return Ok(new + { + id = role.Id, + name = role.Name, + displayName = role.DisplayName, + description = role.Description, + tenantId = role.TenantId, + isSystem = role.IsSystem, + permissions = role.Permissions, + createdAt = role.CreatedTime, + }); + } + + [HttpGet("{id}/users")] + public async Task>> GetRoleUsers(long id) + { + var role = await _context.Roles.FindAsync(id); + if (role == null) + { + return NotFound(); + } + + var users = await _userManager.GetUsersInRoleAsync(role.Name!); + + var result = users.Select(async u => new + { + id = u.Id, + userName = u.UserName, + email = u.Email, + realName = u.RealName, + tenantId = u.TenantId, + roles = await _userManager.GetRolesAsync(u), + isActive = !u.LockoutEnabled || u.LockoutEnd == null || u.LockoutEnd < DateTimeOffset.UtcNow, + createdAt = u.CreatedTime, + }); + + return Ok(await Task.WhenAll(result)); + } + + [HttpPost] + public async Task> CreateRole(CreateRoleDto dto) + { + var role = new ApplicationRole + { + Name = dto.Name, + DisplayName = dto.DisplayName, + Description = dto.Description, + TenantId = dto.TenantId, + Permissions = dto.Permissions, + IsSystem = false, + CreatedTime = DateTime.UtcNow, + }; + + var result = await _roleManager.CreateAsync(role); + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + await CreateAuditLog("role", "create", "Role", role.Id, role.DisplayName, null, SerializeToJson(dto)); + + return CreatedAtAction(nameof(GetRole), new { id = role.Id }, role); + } + + [HttpPut("{id}")] + public async Task UpdateRole(long id, UpdateRoleDto dto) + { + var role = await _context.Roles.FindAsync(id); + if (role == null) + { + return NotFound(); + } + + if (role.IsSystem) + { + return BadRequest("系统角色不能修改"); + } + + var oldValue = System.Text.Json.JsonSerializer.Serialize(role); + + role.DisplayName = dto.DisplayName; + role.Description = dto.Description; + role.Permissions = dto.Permissions; + + await _context.SaveChangesAsync(); + + await CreateAuditLog("role", "update", "Role", role.Id, role.DisplayName, oldValue, System.Text.Json.JsonSerializer.Serialize(role)); + + return NoContent(); + } + + [HttpDelete("{id}")] + public async Task DeleteRole(long id) + { + var role = await _context.Roles.FindAsync(id); + if (role == null) + { + return NotFound(); + } + + if (role.IsSystem) + { + return BadRequest("系统角色不能删除"); + } + + var oldValue = System.Text.Json.JsonSerializer.Serialize(role); + var users = await _userManager.GetUsersInRoleAsync(role.Name!); + + foreach (var user in users) + { + await _userManager.RemoveFromRoleAsync(user, role.Name!); + } + + _context.Roles.Remove(role); + await _context.SaveChangesAsync(); + + await CreateAuditLog("role", "delete", "Role", role.Id, role.DisplayName, oldValue); + + return NoContent(); + } + + [HttpDelete("{id}/users/{userId}")] + public async Task RemoveUserFromRole(long id, long userId) + { + var role = await _context.Roles.FindAsync(id); + if (role == null) + { + return NotFound(); + } + + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) + { + return NotFound(); + } + + var result = await _userManager.RemoveFromRoleAsync(user, role.Name!); + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + await CreateAuditLog("role", "update", "UserRole", null, $"{role.Name} - {user.UserName}"); + + return NoContent(); + } + + private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null) + { + var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system"; + var tenantId = User.FindFirstValue("TenantId"); + + var log = new AuditLog + { + Operator = userName, + TenantId = tenantId, + Operation = operation, + Action = action, + TargetType = targetType, + TargetId = targetId, + TargetName = targetName, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + Status = "success", + OldValue = oldValue, + NewValue = newValue, + }; + + _context.AuditLogs.Add(log); + await _context.SaveChangesAsync(); + } + + private string SerializeToJson(object obj) + { + return System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = false + }); + } +} + +public class CreateRoleDto +{ + public string Name { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string? Description { get; set; } + public long? TenantId { get; set; } + public List Permissions { get; set; } = new(); +} + +public class UpdateRoleDto +{ + public string DisplayName { get; set; } = string.Empty; + public string? Description { get; set; } + public List Permissions { get; set; } = new(); +} diff --git a/Controllers/StatsController.cs b/Controllers/StatsController.cs new file mode 100644 index 0000000..c665ee3 --- /dev/null +++ b/Controllers/StatsController.cs @@ -0,0 +1,62 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class StatsController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly ILogger _logger; + + public StatsController( + ApplicationDbContext context, + ILogger logger) + { + _context = context; + _logger = logger; + } + + [HttpGet("dashboard")] + public async Task> GetDashboardStats() + { + var today = DateTime.UtcNow.Date; + var tomorrow = today.AddDays(1); + + var userCount = await _context.Users.CountAsync(u => !u.IsDeleted); + var tenantCount = await _context.Tenants.CountAsync(t => !t.IsDeleted); + var oauthClientCount = await _context.OAuthApplications.CountAsync(); + var todayAccessCount = await _context.AccessLogs + .CountAsync(l => l.CreatedAt >= today && l.CreatedAt < tomorrow); + + return Ok(new + { + userCount, + tenantCount, + oauthClientCount, + todayAccessCount, + }); + } + + [HttpGet("system")] + public ActionResult GetSystemStats() + { + var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64); + var process = System.Diagnostics.Process.GetCurrentProcess(); + + return Ok(new + { + uptime = $"{uptime.Days}天 {uptime.Hours}小时 {uptime.Minutes}分钟", + memoryUsed = process.WorkingSet64 / 1024 / 1024, + cpuTime = process.TotalProcessorTime, + machineName = Environment.MachineName, + osVersion = Environment.OSVersion.ToString(), + processorCount = Environment.ProcessorCount, + }); + } +} diff --git a/Controllers/TenantsController.cs b/Controllers/TenantsController.cs new file mode 100644 index 0000000..bb2ffc8 --- /dev/null +++ b/Controllers/TenantsController.cs @@ -0,0 +1,344 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using System.Text.Json; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class TenantsController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public TenantsController( + ApplicationDbContext context, + UserManager userManager, + ILogger logger) + { + _context = context; + _userManager = userManager; + _logger = logger; + } + + [HttpGet] + public async Task> GetTenants( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + [FromQuery] string? name = null, + [FromQuery] string? tenantId = null, + [FromQuery] string? status = null) + { + var query = _context.Tenants.AsQueryable(); + + if (!string.IsNullOrEmpty(name)) + { + query = query.Where(t => t.Name.Contains(name)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(t => t.TenantId.Contains(tenantId)); + } + + if (!string.IsNullOrEmpty(status)) + { + query = query.Where(t => t.Status == status); + } + + var totalCount = await query.CountAsync(); + var tenants = await query + .OrderByDescending(t => t.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = new List(); + + foreach (var tenant in tenants) + { + var userCount = await _context.Users.CountAsync(u => u.TenantId == tenant.Id && !u.IsDeleted); + result.Add(new + { + id = tenant.Id, + tenantId = tenant.TenantId, + name = tenant.Name, + contactName = tenant.ContactName, + contactEmail = tenant.ContactEmail, + contactPhone = tenant.ContactPhone, + maxUsers = tenant.MaxUsers, + userCount, + status = tenant.Status, + expiresAt = tenant.ExpiresAt, + description = tenant.Description, + createdAt = tenant.CreatedAt, + }); + } + + return Ok(new + { + items = result, + totalCount, + page, + pageSize + }); + } + + [HttpGet("{id}")] + public async Task> GetTenant(long id) + { + var tenant = await _context.Tenants.FindAsync(id); + if (tenant == null) + { + return NotFound(); + } + + return Ok(new + { + id = tenant.Id, + tenantId = tenant.TenantId, + name = tenant.Name, + contactName = tenant.ContactName, + contactEmail = tenant.ContactEmail, + contactPhone = tenant.ContactPhone, + maxUsers = tenant.MaxUsers, + status = tenant.Status, + expiresAt = tenant.ExpiresAt, + description = tenant.Description, + createdAt = tenant.CreatedAt, + updatedAt = tenant.UpdatedAt, + }); + } + + [HttpGet("{tenantId}/users")] + public async Task>> GetTenantUsers(string tenantId) + { + var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId); + if (tenant == null) + { + return NotFound(); + } + + var users = await _context.Users + .Where(u => u.TenantId == tenant.Id && !u.IsDeleted) + .ToListAsync(); + + var result = users.Select(async u => new + { + id = u.Id, + userName = u.UserName, + email = u.Email, + realName = u.RealName, + tenantId = u.TenantId, + roles = await _userManager.GetRolesAsync(u), + isActive = !u.LockoutEnabled || u.LockoutEnd == null || u.LockoutEnd < DateTimeOffset.UtcNow, + createdAt = u.CreatedTime, + }); + + return Ok(await Task.WhenAll(result)); + } + + [HttpGet("{tenantId}/roles")] + public async Task>> GetTenantRoles(string tenantId) + { + var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId); + if (tenant == null) + { + return NotFound(); + } + + var roles = await _context.Roles + .Where(r => r.TenantId == tenant.Id) + .ToListAsync(); + + var result = roles.Select(r => new + { + id = r.Id, + name = r.Name, + displayName = r.DisplayName, + }); + + return Ok(result); + } + + [HttpGet("{tenantId}/settings")] + public async Task> GetTenantSettings(string tenantId) + { + var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId); + if (tenant == null) + { + return NotFound(); + } + + var settings = new TenantSettings + { + AllowRegistration = false, + AllowedEmailDomains = "", + DefaultRoleId = null, + PasswordPolicy = new List { "requireNumber", "requireLowercase" }, + MinPasswordLength = 8, + SessionTimeout = 120, + }; + + return Ok(settings); + } + + [HttpPut("{tenantId}/settings")] + public async Task UpdateTenantSettings(string tenantId, TenantSettings settings) + { + var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.TenantId == tenantId); + if (tenant == null) + { + return NotFound(); + } + + await CreateAuditLog("tenant", "update", "TenantSettings", tenant.Id, tenant.TenantId, null, JsonSerializer.Serialize(settings)); + + return NoContent(); + } + + [HttpPost] + public async Task> CreateTenant(CreateTenantDto dto) + { + var tenant = new Tenant + { + TenantId = dto.TenantId, + Name = dto.Name, + ContactName = dto.ContactName, + ContactEmail = dto.ContactEmail, + ContactPhone = dto.ContactPhone, + MaxUsers = dto.MaxUsers, + Description = dto.Description, + Status = dto.Status, + ExpiresAt = dto.ExpiresAt, + CreatedAt = DateTime.UtcNow, + }; + + _context.Tenants.Add(tenant); + await _context.SaveChangesAsync(); + + await CreateAuditLog("tenant", "create", "Tenant", tenant.Id, tenant.TenantId, null, JsonSerializer.Serialize(dto)); + + return CreatedAtAction(nameof(GetTenant), new { id = tenant.Id }, tenant); + } + + [HttpPut("{id}")] + public async Task UpdateTenant(long id, UpdateTenantDto dto) + { + var tenant = await _context.Tenants.FindAsync(id); + if (tenant == null) + { + return NotFound(); + } + + var oldValue = JsonSerializer.Serialize(tenant); + + tenant.Name = dto.Name; + tenant.ContactName = dto.ContactName; + tenant.ContactEmail = dto.ContactEmail; + tenant.ContactPhone = dto.ContactPhone; + tenant.MaxUsers = dto.MaxUsers; + tenant.Description = dto.Description; + tenant.Status = dto.Status; + tenant.ExpiresAt = dto.ExpiresAt; + tenant.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + await CreateAuditLog("tenant", "update", "Tenant", tenant.Id, tenant.TenantId, oldValue, JsonSerializer.Serialize(tenant)); + + return NoContent(); + } + + [HttpDelete("{id}")] + public async Task DeleteTenant(long id) + { + var tenant = await _context.Tenants.FindAsync(id); + if (tenant == null) + { + return NotFound(); + } + + var oldValue = JsonSerializer.Serialize(tenant); + + var users = await _context.Users.Where(u => u.TenantId == tenant.Id).ToListAsync(); + foreach (var user in users) + { + user.IsDeleted = true; + user.UpdatedTime = DateTime.UtcNow; + } + + tenant.IsDeleted = true; + await _context.SaveChangesAsync(); + + await CreateAuditLog("tenant", "delete", "Tenant", tenant.Id, tenant.TenantId, oldValue); + + return NoContent(); + } + + private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null) + { + var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system"; + var tenantId = User.FindFirstValue("TenantId"); + + var log = new AuditLog + { + Operator = userName, + TenantId = tenantId, + Operation = operation, + Action = action, + TargetType = targetType, + TargetId = targetId, + TargetName = targetName, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + Status = "success", + OldValue = oldValue, + NewValue = newValue, + }; + + _context.AuditLogs.Add(log); + await _context.SaveChangesAsync(); + } +} + +public class CreateTenantDto +{ + public string TenantId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string ContactName { get; set; } = string.Empty; + public string ContactEmail { get; set; } = string.Empty; + public string? ContactPhone { get; set; } + public int? MaxUsers { get; set; } + public string? Description { get; set; } + public string Status { get; set; } = "active"; + public DateTime? ExpiresAt { get; set; } +} + +public class UpdateTenantDto +{ + public string Name { get; set; } = string.Empty; + public string ContactName { get; set; } = string.Empty; + public string ContactEmail { get; set; } = string.Empty; + public string? ContactPhone { get; set; } + public int? MaxUsers { get; set; } + public string? Description { get; set; } + public string Status { get; set; } = "active"; + public DateTime? ExpiresAt { get; set; } +} + +public class TenantSettings +{ + public bool AllowRegistration { get; set; } + public string AllowedEmailDomains { get; set; } = string.Empty; + public long? DefaultRoleId { get; set; } + public List PasswordPolicy { get; set; } = new(); + public int MinPasswordLength { get; set; } = 8; + public int SessionTimeout { get; set; } = 120; +} diff --git a/Controllers/TokenController.cs b/Controllers/TokenController.cs new file mode 100644 index 0000000..1949792 --- /dev/null +++ b/Controllers/TokenController.cs @@ -0,0 +1,255 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using System.Security.Claims; +using System.Security.Cryptography; +using Microsoft.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("connect")] +public class TokenController( + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager, + IOpenIddictScopeManager scopeManager, + UserManager userManager, + SignInManager signInManager, + ILogger logger) + : ControllerBase +{ + private readonly ILogger _logger = logger; + + [HttpPost("token")] + public async Task Exchange() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("OpenIddict request is null"); + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + + if (request.IsAuthorizationCodeGrantType()) + { + return await ExchangeAuthorizationCodeAsync(request, result); + } + + if (request.IsRefreshTokenGrantType()) + { + return await ExchangeRefreshTokenAsync(request); + } + + if (request.IsPasswordGrantType()) + { + return await ExchangePasswordAsync(request); + } + + return BadRequest(new OpenIddictResponse + { + Error = Errors.UnsupportedGrantType, + ErrorDescription = "The specified grant type is not supported." + }); + } + + private async Task ExchangeAuthorizationCodeAsync(OpenIddictRequest request, + AuthenticateResult result) + { + var application = await applicationManager.FindByClientIdAsync(request.ClientId); + if (application == null) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidClient, + ErrorDescription = "The specified client is invalid." + }); + } + + var authorization = await authorizationManager.FindAsync( + subject: result.Principal?.GetClaim(Claims.Subject), + client: await applicationManager.GetIdAsync(application), + status: Statuses.Valid, + type: AuthorizationTypes.Permanent, + scopes: request.GetScopes()).FirstOrDefaultAsync(); + + if (authorization == null) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidGrant, + ErrorDescription = "The authorization code is invalid." + }); + } + + var user = await userManager.FindByIdAsync(result.Principal?.GetClaim(Claims.Subject)); + if (user == null || user.IsDeleted) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidGrant, + ErrorDescription = "The user is no longer valid." + }); + } + + var claims = new List + { + new(Claims.Subject, await userManager.GetUserIdAsync(user)), + new(Claims.Name, await userManager.GetUserNameAsync(user)), + new(Claims.Email, await userManager.GetEmailAsync(user) ?? ""), + new("tenant_id", user.TenantId.ToString()) + }; + + var roles = await userManager.GetRolesAsync(user); + foreach (var role in roles) + { + claims.Add(new Claim(Claims.Role, role)); + } + + var identity = new ClaimsIdentity(claims, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + + principal.SetScopes(request.GetScopes()); + principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); + principal.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization)); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private async Task ExchangeRefreshTokenAsync(OpenIddictRequest request) + { + var principalResult = + await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + if (principalResult is not { Succeeded: true }) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidGrant, + ErrorDescription = "The refresh token is invalid." + }); + } + + var user = await userManager.GetUserAsync(principalResult.Principal); + if (user == null || user.IsDeleted) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid." + }!)); + } + + // Ensure the user is still allowed to sign in. + if (!await signInManager.CanSignInAsync(user)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The user is no longer allowed to sign in." + }!)); + } + + var principal = principalResult.Principal; + foreach (var claim in principal!.Claims) + { + claim.SetDestinations(GetDestinations(claim, principal)); + } + + + principal.SetScopes(request.GetScopes()); + principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + private IEnumerable GetDestinations(Claim claim, ClaimsPrincipal principal) + { + // Note: by default, claims are NOT automatically included in the access and identity tokens. + // To allow OpenIddict to serialize them, you must attach them a destination, that specifies + // whether they should be included in access tokens, in identity tokens or in both. + + switch (claim.Type) + { + case OpenIddictConstants.Claims.Name: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Profile)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Email: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Email)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + case OpenIddictConstants.Claims.Role: + yield return OpenIddictConstants.Destinations.AccessToken; + + if (principal.HasScope(OpenIddictConstants.Permissions.Scopes.Roles)) + yield return OpenIddictConstants.Destinations.IdentityToken; + + yield break; + + // Never include the security stamp in the access and identity tokens, as it's a secret value. + case "AspNet.Identity.SecurityStamp": yield break; + + default: + yield return OpenIddictConstants.Destinations.AccessToken; + yield break; + } + } + + private async Task ExchangePasswordAsync(OpenIddictRequest request) + { + var user = await userManager.FindByNameAsync(request.Username); + if (user == null || user.IsDeleted) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidGrant, + ErrorDescription = "用户名或密码错误" + }); + } + + var signInResult = await signInManager.CheckPasswordSignInAsync(user, request.Password, false); + if (!signInResult.Succeeded) + { + return BadRequest(new OpenIddictResponse + { + Error = Errors.InvalidGrant, + ErrorDescription = "用户名或密码错误" + }); + } + + var claims = new List + { + new(Claims.Subject, await userManager.GetUserIdAsync(user)), + new(Claims.Name, await userManager.GetUserNameAsync(user)), + new(Claims.Email, await userManager.GetEmailAsync(user) ?? ""), + new("tenant_id", user.TenantId.ToString()) + }; + + var roles = await userManager.GetRolesAsync(user); + foreach (var role in roles) + { + claims.Add(new Claim(Claims.Role, role)); + } + + var identity = new ClaimsIdentity(claims, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + + principal.SetScopes(request.GetScopes()); + principal.SetResources(await scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } +} \ No newline at end of file diff --git a/Controllers/UsersController.cs b/Controllers/UsersController.cs new file mode 100644 index 0000000..5c098c3 --- /dev/null +++ b/Controllers/UsersController.cs @@ -0,0 +1,291 @@ +using Fengling.AuthService.Data; +using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; + +namespace Fengling.AuthService.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class UsersController : ControllerBase +{ + private readonly ApplicationDbContext _context; + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + private readonly ILogger _logger; + + public UsersController( + ApplicationDbContext context, + UserManager userManager, + RoleManager roleManager, + ILogger logger) + { + _context = context; + _userManager = userManager; + _roleManager = roleManager; + _logger = logger; + } + + [HttpGet] + public async Task> GetUsers( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + [FromQuery] string? userName = null, + [FromQuery] string? email = null, + [FromQuery] string? tenantId = null) + { + var query = _context.Users.AsQueryable(); + + if (!string.IsNullOrEmpty(userName)) + { + query = query.Where(u => u.UserName!.Contains(userName)); + } + + if (!string.IsNullOrEmpty(email)) + { + query = query.Where(u => u.Email != null && u.Email.Contains(email)); + } + + if (!string.IsNullOrEmpty(tenantId)) + { + query = query.Where(u => u.TenantId.ToString() == tenantId); + } + + var totalCount = await query.CountAsync(); + var users = await query + .OrderByDescending(u => u.CreatedTime) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var result = users.Select(async u => new + { + id = u.Id, + userName = u.UserName, + email = u.Email, + realName = u.RealName, + phone = u.Phone, + tenantId = u.TenantId, + roles = (await _userManager.GetRolesAsync(u)).ToList(), + emailConfirmed = u.EmailConfirmed, + isActive = !u.LockoutEnabled || u.LockoutEnd == null || u.LockoutEnd < DateTimeOffset.UtcNow, + createdAt = u.CreatedTime, + }); + + return Ok(new + { + items = await Task.WhenAll(result), + totalCount, + page, + pageSize + }); + } + + [HttpGet("{id}")] + public async Task> GetUser(long id) + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound(); + } + + var roles = await _userManager.GetRolesAsync(user); + + return Ok(new + { + id = user.Id, + userName = user.UserName, + email = user.Email, + realName = user.RealName, + phone = user.Phone, + tenantId = user.TenantId, + roles, + emailConfirmed = user.EmailConfirmed, + isActive = !user.LockoutEnabled || user.LockoutEnd == null || user.LockoutEnd < DateTimeOffset.UtcNow, + createdAt = user.CreatedTime, + }); + } + + [HttpPost] + public async Task> CreateUser(CreateUserDto dto) + { + var user = new ApplicationUser + { + UserName = dto.UserName, + Email = dto.Email, + RealName = dto.RealName, + Phone = dto.Phone, + TenantId = dto.TenantId ?? 0, + EmailConfirmed = dto.EmailConfirmed, + CreatedTime = DateTime.UtcNow, + }; + + var result = await _userManager.CreateAsync(user, dto.Password); + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + if (dto.RoleIds != null && dto.RoleIds.Any()) + { + foreach (var roleId in dto.RoleIds) + { + var role = await _roleManager.FindByIdAsync(roleId.ToString()); + if (role != null) + { + await _userManager.AddToRoleAsync(user, role.Name!); + } + } + } + + if (!dto.IsActive) + { + await _userManager.SetLockoutEnabledAsync(user, true); + await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.MaxValue); + } + + await CreateAuditLog("user", "create", "User", user.Id, user.UserName, null, SerializeToJson(dto)); + + return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user); + } + + [HttpPut("{id}")] + public async Task UpdateUser(long id, UpdateUserDto dto) + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound(); + } + + var oldValue = System.Text.Json.JsonSerializer.Serialize(user); + + user.Email = dto.Email; + user.RealName = dto.RealName; + user.Phone = dto.Phone; + user.EmailConfirmed = dto.EmailConfirmed; + user.UpdatedTime = DateTime.UtcNow; + + if (dto.IsActive) + { + await _userManager.SetLockoutEnabledAsync(user, false); + await _userManager.SetLockoutEndDateAsync(user, null); + } + else + { + await _userManager.SetLockoutEnabledAsync(user, true); + await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.MaxValue); + } + + await _context.SaveChangesAsync(); + + await CreateAuditLog("user", "update", "User", user.Id, user.UserName, oldValue, System.Text.Json.JsonSerializer.Serialize(user)); + + return NoContent(); + } + + [HttpPut("{id}/password")] + public async Task ResetPassword(long id, ResetPasswordDto dto) + { + var user = await _userManager.FindByIdAsync(id.ToString()); + if (user == null) + { + return NotFound(); + } + + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, dto.NewPassword); + + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + await CreateAuditLog("user", "reset_password", "User", user.Id, user.UserName); + + return NoContent(); + } + + [HttpDelete("{id}")] + public async Task DeleteUser(long id) + { + var user = await _context.Users.FindAsync(id); + if (user == null) + { + return NotFound(); + } + + var oldValue = System.Text.Json.JsonSerializer.Serialize(user); + user.IsDeleted = true; + user.UpdatedTime = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + await CreateAuditLog("user", "delete", "User", user.Id, user.UserName, oldValue); + + return NoContent(); + } + + private async Task CreateAuditLog(string operation, string action, string targetType, long? targetId, string? targetName, string? oldValue = null, string? newValue = null) + { + var userName = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name ?? "system"; + var tenantId = User.FindFirstValue("TenantId"); + + var log = new AuditLog + { + Operator = userName, + TenantId = tenantId, + Operation = operation, + Action = action, + TargetType = targetType, + TargetId = targetId, + TargetName = targetName, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + Status = "success", + OldValue = oldValue, + NewValue = newValue, + }; + + _context.AuditLogs.Add(log); + await _context.SaveChangesAsync(); + } + + private string SerializeToJson(object obj) + { + return System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = false + }); + } +} + +public class CreateUserDto +{ + public string UserName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string RealName { get; set; } = string.Empty; + public string? Phone { get; set; } + public long? TenantId { get; set; } + public List RoleIds { get; set; } = new(); + public string Password { get; set; } = string.Empty; + public bool EmailConfirmed { get; set; } + public bool IsActive { get; set; } = true; +} + +public class UpdateUserDto +{ + public string Email { get; set; } = string.Empty; + public string RealName { get; set; } = string.Empty; + public string? Phone { get; set; } + public bool EmailConfirmed { get; set; } + public bool IsActive { get; set; } = true; +} + +public class ResetPasswordDto +{ + public string NewPassword { get; set; } = string.Empty; +} diff --git a/DTOs/LoginRequest.cs b/DTOs/LoginRequest.cs deleted file mode 100644 index 9228867..0000000 --- a/DTOs/LoginRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Fengling.AuthService.DTOs; - -public class LoginRequest -{ - public string UserName { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; - public long TenantId { get; set; } -} diff --git a/DTOs/LoginResponse.cs b/DTOs/LoginResponse.cs deleted file mode 100644 index c1f95fd..0000000 --- a/DTOs/LoginResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Fengling.AuthService.DTOs; - -public class LoginResponse -{ - public string AccessToken { get; set; } = string.Empty; - public string RefreshToken { get; set; } = string.Empty; - public int ExpiresIn { get; set; } - public string TokenType { get; set; } = "Bearer"; -} diff --git a/Data/ApplicationDbContext.cs b/Data/ApplicationDbContext.cs index d11a006..dd4e1d5 100644 --- a/Data/ApplicationDbContext.cs +++ b/Data/ApplicationDbContext.cs @@ -12,6 +12,9 @@ public class ApplicationDbContext : IdentityDbContext OAuthApplications { get; set; } + public DbSet Tenants { get; set; } + public DbSet AccessLogs { get; set; } + public DbSet AuditLogs { get; set; } protected override void OnModelCreating(ModelBuilder builder) { @@ -40,6 +43,57 @@ public class ApplicationDbContext : IdentityDbContext e.ClientType).HasMaxLength(20); entity.Property(e => e.ConsentType).HasMaxLength(20); entity.Property(e => e.Status).HasMaxLength(20); + entity.Property(e => e.Description).HasMaxLength(500); + }); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.TenantId).IsUnique(); + entity.Property(e => e.TenantId).HasMaxLength(50); + entity.Property(e => e.Name).HasMaxLength(100); + entity.Property(e => e.ContactName).HasMaxLength(50); + entity.Property(e => e.ContactEmail).HasMaxLength(100); + entity.Property(e => e.ContactPhone).HasMaxLength(20); + entity.Property(e => e.Status).HasMaxLength(20); + entity.Property(e => e.Description).HasMaxLength(500); + }); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.CreatedAt); + entity.HasIndex(e => e.UserName); + entity.HasIndex(e => e.TenantId); + entity.HasIndex(e => e.Action); + entity.HasIndex(e => e.Status); + entity.Property(e => e.UserName).HasMaxLength(50); + entity.Property(e => e.TenantId).HasMaxLength(50); + entity.Property(e => e.Action).HasMaxLength(20); + entity.Property(e => e.Resource).HasMaxLength(200); + entity.Property(e => e.Method).HasMaxLength(10); + entity.Property(e => e.IpAddress).HasMaxLength(50); + entity.Property(e => e.UserAgent).HasMaxLength(500); + entity.Property(e => e.Status).HasMaxLength(20); + }); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.CreatedAt); + entity.HasIndex(e => e.Operator); + entity.HasIndex(e => e.TenantId); + entity.HasIndex(e => e.Operation); + entity.HasIndex(e => e.Action); + entity.Property(e => e.Operator).HasMaxLength(50); + entity.Property(e => e.TenantId).HasMaxLength(50); + entity.Property(e => e.Operation).HasMaxLength(20); + entity.Property(e => e.Action).HasMaxLength(20); + entity.Property(e => e.TargetType).HasMaxLength(50); + entity.Property(e => e.TargetName).HasMaxLength(100); + entity.Property(e => e.IpAddress).HasMaxLength(50); + entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.Status).HasMaxLength(20); }); } } diff --git a/Data/Migrations/20260202031310_AddTenantAndLogs.Designer.cs b/Data/Migrations/20260202031310_AddTenantAndLogs.Designer.cs new file mode 100644 index 0000000..b03ca1a --- /dev/null +++ b/Data/Migrations/20260202031310_AddTenantAndLogs.Designer.cs @@ -0,0 +1,621 @@ +// +using System; +using System.Collections.Generic; +using Fengling.AuthService.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.AuthService.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260202031310_AddTenantAndLogs")] + partial class AddTenantAndLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fengling.AuthService.Models.AccessLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Method") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RequestData") + .HasColumnType("text"); + + b.Property("Resource") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResponseData") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserName"); + + b.ToTable("AccessLogs"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Permissions") + .HasColumnType("text[]"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RealName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("Phone") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NewValue") + .HasColumnType("text"); + + b.Property("OldValue") + .HasColumnType("text"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TargetId") + .HasColumnType("bigint"); + + b.Property("TargetName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Operation"); + + b.HasIndex("Operator"); + + b.HasIndex("TenantId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.OAuthApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ConsentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.PrimitiveCollection("GrantTypes") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("PostLogoutRedirectUris") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("RedirectUris") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Scopes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OAuthApplications"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MaxUsers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b => + { + b.HasOne("Fengling.AuthService.Models.Tenant", null) + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260202031310_AddTenantAndLogs.cs b/Data/Migrations/20260202031310_AddTenantAndLogs.cs new file mode 100644 index 0000000..5a8aa57 --- /dev/null +++ b/Data/Migrations/20260202031310_AddTenantAndLogs.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.AuthService.Data.Migrations +{ + /// + public partial class AddTenantAndLogs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DisplayName", + table: "AspNetRoles", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsSystem", + table: "AspNetRoles", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn>( + name: "Permissions", + table: "AspNetRoles", + type: "text[]", + nullable: true); + + migrationBuilder.AddColumn( + name: "TenantId", + table: "AspNetRoles", + type: "bigint", + nullable: true); + + migrationBuilder.CreateTable( + name: "AccessLogs", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserName = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + TenantId = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Action = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Resource = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Method = table.Column(type: "character varying(10)", maxLength: 10, nullable: true), + IpAddress = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + UserAgent = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Duration = table.Column(type: "integer", nullable: false), + RequestData = table.Column(type: "text", nullable: true), + ResponseData = table.Column(type: "text", nullable: true), + ErrorMessage = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccessLogs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Operator = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + TenantId = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Operation = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Action = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + TargetType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + TargetId = table.Column(type: "bigint", nullable: true), + TargetName = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + IpAddress = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + OldValue = table.Column(type: "text", nullable: true), + NewValue = table.Column(type: "text", nullable: true), + ErrorMessage = table.Column(type: "text", nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Tenants", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TenantId = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ContactName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + ContactEmail = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ContactPhone = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + MaxUsers = table.Column(type: "integer", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tenants", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AccessLogs_Action", + table: "AccessLogs", + column: "Action"); + + migrationBuilder.CreateIndex( + name: "IX_AccessLogs_CreatedAt", + table: "AccessLogs", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_AccessLogs_Status", + table: "AccessLogs", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_AccessLogs_TenantId", + table: "AccessLogs", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_AccessLogs_UserName", + table: "AccessLogs", + column: "UserName"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_Action", + table: "AuditLogs", + column: "Action"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_CreatedAt", + table: "AuditLogs", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_Operation", + table: "AuditLogs", + column: "Operation"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_Operator", + table: "AuditLogs", + column: "Operator"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_TenantId", + table: "AuditLogs", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_Tenants_TenantId", + table: "Tenants", + column: "TenantId", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_AspNetUsers_Tenants_TenantId", + table: "AspNetUsers", + column: "TenantId", + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AspNetUsers_Tenants_TenantId", + table: "AspNetUsers"); + + migrationBuilder.DropTable( + name: "AccessLogs"); + + migrationBuilder.DropTable( + name: "AuditLogs"); + + migrationBuilder.DropTable( + name: "Tenants"); + + migrationBuilder.DropColumn( + name: "DisplayName", + table: "AspNetRoles"); + + migrationBuilder.DropColumn( + name: "IsSystem", + table: "AspNetRoles"); + + migrationBuilder.DropColumn( + name: "Permissions", + table: "AspNetRoles"); + + migrationBuilder.DropColumn( + name: "TenantId", + table: "AspNetRoles"); + } + } +} diff --git a/Data/Migrations/20260202064650_AddOAuthDescription.Designer.cs b/Data/Migrations/20260202064650_AddOAuthDescription.Designer.cs new file mode 100644 index 0000000..47f017a --- /dev/null +++ b/Data/Migrations/20260202064650_AddOAuthDescription.Designer.cs @@ -0,0 +1,625 @@ +// +using System; +using System.Collections.Generic; +using Fengling.AuthService.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.AuthService.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260202064650_AddOAuthDescription")] + partial class AddOAuthDescription + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fengling.AuthService.Models.AccessLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Method") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RequestData") + .HasColumnType("text"); + + b.Property("Resource") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResponseData") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserName"); + + b.ToTable("AccessLogs"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Permissions") + .HasColumnType("text[]"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RealName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("Phone") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NewValue") + .HasColumnType("text"); + + b.Property("OldValue") + .HasColumnType("text"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TargetId") + .HasColumnType("bigint"); + + b.Property("TargetName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Operation"); + + b.HasIndex("Operator"); + + b.HasIndex("TenantId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.OAuthApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ConsentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.PrimitiveCollection("GrantTypes") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("PostLogoutRedirectUris") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("RedirectUris") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("Scopes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OAuthApplications"); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MaxUsers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b => + { + b.HasOne("Fengling.AuthService.Models.Tenant", null) + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Fengling.AuthService.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260202064650_AddOAuthDescription.cs b/Data/Migrations/20260202064650_AddOAuthDescription.cs new file mode 100644 index 0000000..9f6d766 --- /dev/null +++ b/Data/Migrations/20260202064650_AddOAuthDescription.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Fengling.AuthService.Data.Migrations +{ + /// + public partial class AddOAuthDescription : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Description", + table: "OAuthApplications", + type: "character varying(500)", + maxLength: 500, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Description", + table: "OAuthApplications"); + } + } +} diff --git a/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 23a3a85..2dd38f3 100644 --- a/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Fengling.AuthService.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -22,6 +23,78 @@ namespace Fengling.AuthService.Data.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Fengling.AuthService.Models.AccessLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Method") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RequestData") + .HasColumnType("text"); + + b.Property("Resource") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResponseData") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserName"); + + b.ToTable("AccessLogs"); + }); + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationRole", b => { b.Property("Id") @@ -41,6 +114,12 @@ namespace Fengling.AuthService.Data.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + b.Property("Name") .HasMaxLength(256) .HasColumnType("character varying(256)"); @@ -49,6 +128,12 @@ namespace Fengling.AuthService.Data.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.PrimitiveCollection>("Permissions") + .HasColumnType("text[]"); + + b.Property("TenantId") + .HasColumnType("bigint"); + b.HasKey("Id"); b.HasIndex("NormalizedName") @@ -150,6 +235,85 @@ namespace Fengling.AuthService.Data.Migrations b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("Fengling.AuthService.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NewValue") + .HasColumnType("text"); + + b.Property("OldValue") + .HasColumnType("text"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TargetId") + .HasColumnType("bigint"); + + b.Property("TargetName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Operation"); + + b.HasIndex("Operator"); + + b.HasIndex("TenantId"); + + b.ToTable("AuditLogs"); + }); + modelBuilder.Entity("Fengling.AuthService.Models.OAuthApplication", b => { b.Property("Id") @@ -180,6 +344,10 @@ namespace Fengling.AuthService.Data.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + b.Property("DisplayName") .IsRequired() .HasMaxLength(100) @@ -217,6 +385,70 @@ namespace Fengling.AuthService.Data.Migrations b.ToTable("OAuthApplications"); }); + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MaxUsers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("Tenants"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") @@ -320,6 +552,15 @@ namespace Fengling.AuthService.Data.Migrations b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("Fengling.AuthService.Models.ApplicationUser", b => + { + b.HasOne("Fengling.AuthService.Models.Tenant", null) + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Fengling.AuthService.Models.ApplicationRole", null) @@ -370,6 +611,11 @@ namespace Fengling.AuthService.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b => + { + b.Navigation("Users"); + }); #pragma warning restore 612, 618 } } diff --git a/Data/SeedData.cs b/Data/SeedData.cs index cd659a2..017c766 100644 --- a/Data/SeedData.cs +++ b/Data/SeedData.cs @@ -15,18 +15,65 @@ public static class SeedData context.Database.EnsureCreated(); + var defaultTenant = await context.Tenants + .FirstOrDefaultAsync(t => t.TenantId == "default"); + if (defaultTenant == null) + { + defaultTenant = new Tenant + { + TenantId = "default", + Name = "默认租户", + ContactName = "系统管理员", + ContactEmail = "admin@fengling.local", + ContactPhone = "13800138000", + MaxUsers = 1000, + Description = "系统默认租户", + Status = "active", + CreatedAt = DateTime.UtcNow + }; + context.Tenants.Add(defaultTenant); + await context.SaveChangesAsync(); + } + var adminRole = await roleManager.FindByNameAsync("Admin"); if (adminRole == null) { adminRole = new ApplicationRole { Name = "Admin", + DisplayName = "管理员", Description = "System administrator", + TenantId = defaultTenant.Id, + IsSystem = true, + Permissions = new List + { + "user.manage", "user.view", + "role.manage", "role.view", + "tenant.manage", "tenant.view", + "oauth.manage", "oauth.view", + "log.view", "system.config" + }, CreatedTime = DateTime.UtcNow }; await roleManager.CreateAsync(adminRole); } + var userRole = await roleManager.FindByNameAsync("User"); + if (userRole == null) + { + userRole = new ApplicationRole + { + Name = "User", + DisplayName = "普通用户", + Description = "Regular user", + TenantId = defaultTenant.Id, + IsSystem = true, + Permissions = new List { "user.view" }, + CreatedTime = DateTime.UtcNow + }; + await roleManager.CreateAsync(userRole); + } + var adminUser = await userManager.FindByNameAsync("admin"); if (adminUser == null) { @@ -36,7 +83,7 @@ public static class SeedData Email = "admin@fengling.local", RealName = "系统管理员", Phone = "13800138000", - TenantId = 1, + TenantId = defaultTenant.Id, EmailConfirmed = true, IsDeleted = false, CreatedTime = DateTime.UtcNow @@ -58,7 +105,7 @@ public static class SeedData Email = "test@fengling.local", RealName = "测试用户", Phone = "13900139000", - TenantId = 1, + TenantId = defaultTenant.Id, EmailConfirmed = true, IsDeleted = false, CreatedTime = DateTime.UtcNow @@ -67,13 +114,6 @@ public static class SeedData var result = await userManager.CreateAsync(testUser, "Test@123"); if (result.Succeeded) { - var userRole = new ApplicationRole - { - Name = "User", - Description = "普通用户", - CreatedTime = DateTime.UtcNow - }; - await roleManager.CreateAsync(userRole); await userManager.AddToRoleAsync(testUser, "User"); } } diff --git a/Fengling.AuthService.csproj b/Fengling.AuthService.csproj index e31fbd7..30dfedf 100644 --- a/Fengling.AuthService.csproj +++ b/Fengling.AuthService.csproj @@ -6,23 +6,30 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - - - - - + + + + + + + + + + + + + PreserveNewest + diff --git a/Models/AccessLog.cs b/Models/AccessLog.cs new file mode 100644 index 0000000..d0c4a2e --- /dev/null +++ b/Models/AccessLog.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fengling.AuthService.Models; + +public class AccessLog +{ + [Key] + public long Id { get; set; } + + [MaxLength(50)] + public string? UserName { get; set; } + + [MaxLength(50)] + public string? TenantId { get; set; } + + [MaxLength(20)] + public string Action { get; set; } = string.Empty; + + [MaxLength(200)] + public string? Resource { get; set; } + + [MaxLength(10)] + public string? Method { get; set; } + + [MaxLength(50)] + public string? IpAddress { get; set; } + + [MaxLength(500)] + public string? UserAgent { get; set; } + + [MaxLength(20)] + public string Status { get; set; } = "success"; + + public int Duration { get; set; } + + public string? RequestData { get; set; } + + public string? ResponseData { get; set; } + + public string? ErrorMessage { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Models/ApplicationRole.cs b/Models/ApplicationRole.cs index 612de44..a015315 100644 --- a/Models/ApplicationRole.cs +++ b/Models/ApplicationRole.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Identity; +using System.ComponentModel.DataAnnotations.Schema; namespace Fengling.AuthService.Models; @@ -6,4 +7,8 @@ public class ApplicationRole : IdentityRole { public string? Description { get; set; } public DateTime CreatedTime { get; set; } = DateTime.UtcNow; + public long? TenantId { get; set; } + public bool IsSystem { get; set; } + public string? DisplayName { get; set; } + public List? Permissions { get; set; } } diff --git a/Models/AuditLog.cs b/Models/AuditLog.cs new file mode 100644 index 0000000..60708c6 --- /dev/null +++ b/Models/AuditLog.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fengling.AuthService.Models; + +public class AuditLog +{ + [Key] + public long Id { get; set; } + + [MaxLength(50)] + [Required] + public string Operator { get; set; } = string.Empty; + + [MaxLength(50)] + public string? TenantId { get; set; } + + [MaxLength(20)] + public string Operation { get; set; } = string.Empty; + + [MaxLength(20)] + public string Action { get; set; } = string.Empty; + + [MaxLength(50)] + public string? TargetType { get; set; } + + public long? TargetId { get; set; } + + [MaxLength(100)] + public string? TargetName { get; set; } + + [MaxLength(50)] + public string IpAddress { get; set; } = string.Empty; + + [MaxLength(500)] + public string? Description { get; set; } + + public string? OldValue { get; set; } + + public string? NewValue { get; set; } + + public string? ErrorMessage { get; set; } + + [MaxLength(20)] + public string Status { get; set; } = "success"; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Models/OAuthApplication.cs b/Models/OAuthApplication.cs index 2b79d74..59d3b11 100644 --- a/Models/OAuthApplication.cs +++ b/Models/OAuthApplication.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace Fengling.AuthService.Models; public class OAuthApplication @@ -13,6 +15,7 @@ public class OAuthApplication public string ClientType { get; set; } = "public"; public string ConsentType { get; set; } = "implicit"; public string Status { get; set; } = "active"; + public string? Description { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } } diff --git a/Models/Tenant.cs b/Models/Tenant.cs new file mode 100644 index 0000000..d25c7c2 --- /dev/null +++ b/Models/Tenant.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fengling.AuthService.Models; + +public class Tenant +{ + [Key] + public long Id { get; set; } + + [MaxLength(50)] + [Required] + public string TenantId { get; set; } = string.Empty; + + [MaxLength(100)] + [Required] + public string Name { get; set; } = string.Empty; + + [MaxLength(50)] + [Required] + public string ContactName { get; set; } = string.Empty; + + [MaxLength(100)] + [Required] + [EmailAddress] + public string ContactEmail { get; set; } = string.Empty; + + [MaxLength(20)] + public string? ContactPhone { get; set; } + + public int? MaxUsers { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [MaxLength(500)] + public string? Description { get; set; } + + [MaxLength(20)] + public string Status { get; set; } = "active"; + + public DateTime? ExpiresAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public bool IsDeleted { get; set; } + + public ICollection Users { get; set; } = new List(); +} diff --git a/Program.cs b/Program.cs index eaa2ba4..3640d60 100644 --- a/Program.cs +++ b/Program.cs @@ -1,9 +1,10 @@ using Fengling.AuthService.Configuration; using Fengling.AuthService.Data; using Fengling.AuthService.Models; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using OpenTelemetry; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -19,13 +20,37 @@ Log.Logger = new LoggerConfiguration() builder.Host.UseSerilog(); +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +{ + if (connectionString.StartsWith("DataSource=")) + { + options.UseInMemoryDatabase(connectionString); + } + else + { + options.UseNpgsql(connectionString); + } +}); + +builder.Services.AddRazorPages(); +builder.Services.AddControllersWithViews(); builder.Services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; +}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => +{ + options.Cookie.Name = "Fengling.Auth"; + options.Cookie.SecurePolicy = CookieSecurePolicy.None; + options.Cookie.SameSite = SameSiteMode.Lax; + options.ExpireTimeSpan = TimeSpan.FromDays(7); +}); + builder.Services.AddOpenIddictConfiguration(builder.Configuration); builder.Services.AddOpenTelemetry() @@ -37,7 +62,7 @@ builder.Services.AddOpenTelemetry() .AddSource("OpenIddict.Server.AspNetCore") .AddOtlpExporter()); -builder.Services.AddControllers(); +builder.Services.AddControllersWithViews(); builder.Services.AddHealthChecks() .AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection")!); @@ -71,18 +96,24 @@ using (var scope = app.Services.CreateScope()) await SeedData.Initialize(scope.ServiceProvider); } -app.UseSwagger(); -app.UseSwaggerUI(options => -{ - options.SwaggerEndpoint("/swagger/v1/swagger.json", "Fengling Auth Service v1"); - options.OAuthClientId("swagger-ui"); - options.OAuthUsePkce(); -}); - +app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); +var isTesting = builder.Configuration.GetValue("Testing", false); +if (!isTesting) +{ + app.UseSwagger(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/swagger/v1/swagger.json", "Fengling Auth Service v1"); + options.OAuthClientId("swagger-ui"); + options.OAuthUsePkce(); + }); +} + +app.MapRazorPages(); app.MapControllers(); app.MapHealthChecks("/health"); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json index b8d0b90..8fa6f08 100644 --- a/Properties/launchSettings.json +++ b/Properties/launchSettings.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "http": { @@ -9,15 +9,6 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7150;http://localhost:5132", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } diff --git a/ViewModels/AuthorizeViewModel.cs b/ViewModels/AuthorizeViewModel.cs new file mode 100644 index 0000000..088f5db --- /dev/null +++ b/ViewModels/AuthorizeViewModel.cs @@ -0,0 +1,6 @@ +namespace Fengling.AuthService.ViewModels; + +public record AuthorizeViewModel(string? ApplicationName, string? Scope) +{ + public string[]? Scopes => Scope?.Split(' ') ?? null; +} \ No newline at end of file diff --git a/ViewModels/DashboardViewModel.cs b/ViewModels/DashboardViewModel.cs new file mode 100644 index 0000000..5ff159b --- /dev/null +++ b/ViewModels/DashboardViewModel.cs @@ -0,0 +1,7 @@ +namespace Fengling.AuthService.ViewModels; + +public class DashboardViewModel +{ + public string? Username { get; set; } + public string? Email { get; set; } +} diff --git a/ViewModels/LoginViewModel.cs b/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..bddc0fd --- /dev/null +++ b/ViewModels/LoginViewModel.cs @@ -0,0 +1,14 @@ +namespace Fengling.AuthService.ViewModels; + +public class LoginViewModel +{ + public string ReturnUrl { get; set; } +} + +public class LoginInputModel +{ + public string Username { get; set; } + public string Password { get; set; } + public bool RememberMe { get; set; } + public string ReturnUrl { get; set; } +} \ No newline at end of file diff --git a/ViewModels/RegisterViewModel.cs b/ViewModels/RegisterViewModel.cs new file mode 100644 index 0000000..555da86 --- /dev/null +++ b/ViewModels/RegisterViewModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fengling.AuthService.ViewModels; + +public class RegisterViewModel +{ + [Required(ErrorMessage = "用户名不能为空")] + [StringLength(50, MinimumLength = 3, ErrorMessage = "用户名长度必须在3-50个字符之间")] + public string Username { get; set; } + + [Required(ErrorMessage = "邮箱不能为空")] + [EmailAddress(ErrorMessage = "请输入有效的邮箱地址")] + public string Email { get; set; } + + [Required(ErrorMessage = "密码不能为空")] + [StringLength(100, MinimumLength = 6, ErrorMessage = "密码长度必须在6-100个字符之间")] + [DataType(DataType.Password)] + public string Password { get; set; } + + [Required(ErrorMessage = "确认密码不能为空")] + [DataType(DataType.Password)] + [Compare("Password", ErrorMessage = "两次输入的密码不一致")] + public string ConfirmPassword { get; set; } + + public string ReturnUrl { get; set; } +} \ No newline at end of file diff --git a/Views/Account/Login.cshtml b/Views/Account/Login.cshtml new file mode 100644 index 0000000..156b39f --- /dev/null +++ b/Views/Account/Login.cshtml @@ -0,0 +1,81 @@ + @model Fengling.AuthService.ViewModels.LoginInputModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "登录"; +} + +
+
+
+
+ + + + + +
+

欢迎回来

+

登录到 Fengling Auth

+
+ +
+ @if (!ViewData.ModelState.IsValid) + { +
+ @foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors)) + { +

@error.ErrorMessage

+ } +
+ } + +
+ + +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ 还没有账号? + 立即注册 +
+
+
diff --git a/Views/Account/Register.cshtml b/Views/Account/Register.cshtml new file mode 100644 index 0000000..cd58d86 --- /dev/null +++ b/Views/Account/Register.cshtml @@ -0,0 +1,95 @@ +@model Fengling.AuthService.ViewModels.RegisterViewModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "注册"; +} + +
+
+
+
+ + + + + + +
+

创建账号

+

加入 Fengling Auth

+
+ +
+ @if (!ViewData.ModelState.IsValid) + { +
+ @foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors)) + { +

@error.ErrorMessage

+ } +
+ } + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+ 已有账号? + 立即登录 +
+
+
diff --git a/Views/Authorization/Authorize.cshtml b/Views/Authorization/Authorize.cshtml new file mode 100644 index 0000000..dc4dda9 --- /dev/null +++ b/Views/Authorization/Authorize.cshtml @@ -0,0 +1,146 @@ +@model Fengling.AuthService.ViewModels.AuthorizeViewModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "授权确认"; +} + +
+
+ +
+
+ + + +
+

授权确认

+

+ @Model.ApplicationName + 请求访问您的账户 +

+
+ + +
+ +
+
+
+
+ @(Model.ApplicationName?.Substring(0, Math.Min(1, Model.ApplicationName.Length)).ToUpper() ?? "A") +
+
+
+

@Model.ApplicationName

+

+ 该应用将获得以下权限: +

+
+
+
+ + +
+

请求的权限

+
+ @if (Model.Scopes != null && Model.Scopes.Length > 0) + { + @foreach (var scope in Model.Scopes) + { +
+ + + + +
+

@GetScopeDisplayName(scope)

+

@GetScopeDescription(scope)

+
+
+ } + } + else + { +

无特定权限请求

+ } +
+
+ + +
+
+ + + + + +

+ 授予权限后,该应用将能够访问您的账户信息。您可以随时在授权管理中撤销权限。 +

+
+
+
+ + +
+
+ + +
+
+ + + +
+
+ +@functions { + private string GetScopeDisplayName(string scope) + { + return scope switch + { + "openid" => "OpenID Connect", + "profile" => "个人资料", + "email" => "电子邮件地址", + "phone" => "电话号码", + "address" => "地址信息", + "roles" => "角色权限", + "offline_access" => "离线访问", + _ => scope + }; + } + + private string GetScopeDescription(string scope) + { + return scope switch + { + "openid" => "用于用户身份验证", + "profile" => "访问您的姓名、头像等基本信息", + "email" => "访问您的电子邮件地址", + "phone" => "访问您的电话号码", + "address" => "访问您的地址信息", + "roles" => "访问您的角色和权限信息", + "offline_access" => "在您离线时仍可访问数据", + _ => "自定义权限范围" + }; + } +} diff --git a/Views/Dashboard/Index.cshtml b/Views/Dashboard/Index.cshtml new file mode 100644 index 0000000..03198a8 --- /dev/null +++ b/Views/Dashboard/Index.cshtml @@ -0,0 +1,155 @@ +@model Fengling.AuthService.ViewModels.DashboardViewModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "控制台"; +} + +
+
+

欢迎,@Model.Username

+

这里是您的控制台首页

+
+ +
+
+
+
+

已登录应用

+

3

+
+
+ + + + + +
+
+
+ +
+
+
+

授权次数

+

12

+
+
+ + + + +
+
+
+ +
+
+
+

活跃会话

+

5

+
+
+ + + + +
+
+
+ +
+
+
+

安全评分

+

92%

+
+
+ + + +
+
+
+
+ +
+
+

最近活动

+
+
+
+ F +
+
+

登录成功

+

通过 Fengling.Console.Web 登录

+
+ 2分钟前 +
+ +
+
+ ✓ +
+
+

授权成功

+

授予 Fengling.Console.Web 访问权限

+
+ 5分钟前 +
+ +
+
+ 🔄 +
+
+

令牌刷新

+

刷新访问令牌

+
+ 1小时前 +
+
+
+ +
+

快捷操作

+ +
+
+
diff --git a/Views/Dashboard/Profile.cshtml b/Views/Dashboard/Profile.cshtml new file mode 100644 index 0000000..bbeec08 --- /dev/null +++ b/Views/Dashboard/Profile.cshtml @@ -0,0 +1,50 @@ +@model Fengling.AuthService.ViewModels.DashboardViewModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "个人资料"; +} + +
+
+

个人资料

+

管理您的个人信息

+
+ +
+
+
+
+ @(Model.Username?.Substring(0, 1).ToUpper() ?? "U") +
+
+

@Model.Username

+

@Model.Email

+
+
+ +
+
+ +
+ @Model.Username +
+
+ +
+ +
+ @Model.Email +
+
+ +
+ +
+ 2026-01-15 +
+
+
+
+
+
diff --git a/Views/Dashboard/Settings.cshtml b/Views/Dashboard/Settings.cshtml new file mode 100644 index 0000000..d4e311d --- /dev/null +++ b/Views/Dashboard/Settings.cshtml @@ -0,0 +1,90 @@ +@model Fengling.AuthService.ViewModels.DashboardViewModel + +@{ + Layout = "_Layout"; + ViewData["Title"] = "设置"; +} + +
+
+

账户设置

+

管理您的账户设置和偏好

+
+ +
+
+

修改密码

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+

安全选项

+
+
+
+

两步验证

+

为您的账户添加额外的安全保护

+
+ +
+ +
+
+

登录通知

+

当有新设备登录时发送通知

+
+ +
+
+
+ +
+

危险区域

+
+
+

删除账户

+

永久删除您的账户和所有数据

+
+ +
+
+
+
diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..533acb9 --- /dev/null +++ b/Views/Shared/_Layout.cshtml @@ -0,0 +1,162 @@ + + + + + + + + + + @ViewData["Title"] - Fengling Auth + + + + + + + + + + +
+
+ +
+
+ + + + + +
+ Fengling Auth +
+ + + + + +
+ @if (User.Identity?.IsAuthenticated == true) + { + +
+ + + + +
+ } + else + { + + 登录 + + 注册 + + } +
+
+
+ + +
+ @RenderBody() +
+ + + + + + \ No newline at end of file diff --git a/Views/_ViewImports.cshtml b/Views/_ViewImports.cshtml new file mode 100644 index 0000000..1dbdf3d --- /dev/null +++ b/Views/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using Fengling.AuthService +@using Fengling.AuthService.ViewModels +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers \ No newline at end of file diff --git a/Views/_ViewStart.cshtml b/Views/_ViewStart.cshtml new file mode 100644 index 0000000..1af6e49 --- /dev/null +++ b/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} \ No newline at end of file diff --git a/appsettings.Testing.json b/appsettings.Testing.json new file mode 100644 index 0000000..bcf5701 --- /dev/null +++ b/appsettings.Testing.json @@ -0,0 +1,22 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "DataSource=:memory:" + }, + "Jwt": { + "Issuer": "https://auth.fengling.local", + "Audience": "fengling-api", + "Secret": "FenglingAuthSecretKey2024!ChangeThisInProduction!" + }, + "OpenIddict": { + "Issuer": "https://auth.fengling.local", + "Audience": "fengling-api" + }, + "Testing": true, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/appsettings.json b/appsettings.json index b0df93d..14eeb16 100644 --- a/appsettings.json +++ b/appsettings.json @@ -2,6 +2,11 @@ "ConnectionStrings": { "DefaultConnection": "Host=192.168.100.10;Port=5432;Database=fengling_auth;Username=movingsam;Password=sl52788542" }, + "Jwt": { + "Issuer": "https://auth.fengling.local", + "Audience": "fengling-api", + "Secret": "FenglingAuthSecretKey2024!ChangeThisInProduction!" + }, "OpenIddict": { "Issuer": "https://auth.fengling.local", "Audience": "fengling-api" diff --git a/wwwroot/css/styles.css b/wwwroot/css/styles.css new file mode 100644 index 0000000..e3191b4 --- /dev/null +++ b/wwwroot/css/styles.css @@ -0,0 +1,210 @@ +/* ============================================ + shadcn UI Theme Variables + Based on shadcn/ui default theme + ============================================ */ + +/* Light Mode Variables */ +:root { + /* Background & Foreground */ + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + /* Card */ + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + /* Popover */ + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + /* Primary */ + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + /* Secondary */ + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + /* Muted */ + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + /* Accent */ + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + /* Destructive */ + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + /* Borders & Inputs */ + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + + /* Ring */ + --ring: 222.2 84% 4.9%; + + /* Radius */ + --radius: 0.5rem; +} + +/* Dark Mode Variables */ +.dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; +} + +/* ============================================ + Base Styles + ============================================ */ + +* { + border-color: hsl(var(--border)); +} + +body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-feature-settings: "rlig" 1, "calt" 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ============================================ + Utility Classes + ============================================ */ + +/* Background colors */ +.bg-primary { + background-color: hsl(var(--primary)); +} + +.bg-secondary { + background-color: hsl(var(--secondary)); +} + +.bg-muted { + background-color: hsl(var(--muted)); +} + +.bg-accent { + background-color: hsl(var(--accent)); +} + +.bg-destructive { + background-color: hsl(var(--destructive)); +} + +/* Text colors */ +.text-primary-foreground { + color: hsl(var(--primary-foreground)); +} + +.text-secondary-foreground { + color: hsl(var(--secondary-foreground)); +} + +.text-muted-foreground { + color: hsl(var(--muted-foreground)); +} + +.text-accent-foreground { + color: hsl(var(--accent-foreground)); +} + +.text-destructive-foreground { + color: hsl(var(--destructive-foreground)); +} + +.text-foreground { + color: hsl(var(--foreground)); +} + +/* Borders */ +.border-border { + border-color: hsl(var(--border)); +} + +/* Hover effects */ +.hover\:bg-muted:hover { + background-color: hsl(var(--muted)); +} + +.hover\:bg-primary:hover { + background-color: hsl(var(--primary)); +} + +.hover\:text-foreground:hover { + color: hsl(var(--foreground)); +} + +/* ============================================ + Transitions + ============================================ */ + +.transition-colors { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* ============================================ + Component Styles + ============================================ */ + +/* Button Styles */ +.btn-primary { + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.btn-primary:hover { + background-color: hsl(var(--primary) / 0.9); +} + +/* Card Styles */ +.card { + background-color: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); +} + +.card-foreground { + color: hsl(var(--card-foreground)); +} + +/* Input Styles */ +.input { + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--input)); + color: hsl(var(--foreground)); +} + +.input:focus { + outline: none; + border-color: hsl(var(--ring)); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); +} diff --git a/wwwroot/login.html b/wwwroot/login.html new file mode 100644 index 0000000..2bf3304 --- /dev/null +++ b/wwwroot/login.html @@ -0,0 +1,193 @@ + + + + + + 登录 - 风铃认证服务 + + + +
+

风铃认证服务

+ +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + +