first commit

This commit is contained in:
Sam 2026-02-03 15:30:12 +08:00
parent 1b815e59fd
commit 02b446cfa7
48 changed files with 5565 additions and 186 deletions

View File

@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Validation.AspNetCore;
namespace Fengling.AuthService.Configuration; namespace Fengling.AuthService.Configuration;
@ -11,42 +11,41 @@ public static class OpenIddictSetup
IConfiguration configuration IConfiguration configuration
) )
{ {
services var isTesting = configuration.GetValue<bool>("Testing", false);
.AddOpenIddict()
.AddCore(options => var builder = services.AddOpenIddict();
builder.AddCore(options =>
{ {
options.UseEntityFrameworkCore().UseDbContext<Data.ApplicationDbContext>(); options.UseEntityFrameworkCore().UseDbContext<Data.ApplicationDbContext>();
}) });
.AddServer(options =>
if (!isTesting)
{ {
options.SetIssuer( builder.AddServer(options =>
configuration["OpenIddict:Issuer"] ?? "https://auth.fengling.local" {
); options.SetIssuer(configuration["OpenIddict:Issuer"] ?? "https://auth.fengling.local");
options.AddDevelopmentEncryptionCertificate().AddDevelopmentSigningCertificate(); options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
options options.AllowAuthorizationCodeFlow()
.AllowAuthorizationCodeFlow()
.AllowPasswordFlow() .AllowPasswordFlow()
.AllowRefreshTokenFlow() .AllowRefreshTokenFlow()
.RequireProofKeyForCodeExchange(); .RequireProofKeyForCodeExchange();
options.RegisterScopes("api", "offline_access"); options.RegisterScopes("api", "offline_access");
});
}
options.UseAspNetCore(); builder.AddValidation(options =>
})
.AddValidation(options =>
{ {
options.UseLocalServer(); options.UseLocalServer();
options.UseAspNetCore();
}); });
services.AddAuthentication(options => services.AddAuthentication(options =>
{ {
options.DefaultAuthenticateScheme = options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme =
OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
}); });
return services; return services;

View File

@ -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<AccessLogsController> _logger;
public AccessLogsController(
ApplicationDbContext context,
ILogger<AccessLogsController> logger)
{
_context = context;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<object>> 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<IActionResult> 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");
}
}

View File

@ -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<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ILogger<AccountController> _logger;
public AccountController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ILogger<AccountController> 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<IActionResult> 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<IActionResult> 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<IActionResult> LogoutPost()
{
await _signInManager.SignOutAsync();
return Redirect("/");
}
}

View File

@ -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<AuditLogsController> _logger;
public AuditLogsController(
ApplicationDbContext context,
ILogger<AuditLogsController> logger)
{
_context = context;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<object>> 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<IActionResult> 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");
}
}

View File

@ -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<ApplicationUser> _signInManager;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IOpenIddictAuthorizationManager _authorizationManager;
private readonly IOpenIddictScopeManager _scopeManager;
private readonly ILogger<AuthController> _logger;
public AuthController(
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager,
IOpenIddictApplicationManager applicationManager,
IOpenIddictAuthorizationManager authorizationManager,
IOpenIddictScopeManager scopeManager,
ILogger<AuthController> logger)
{
_signInManager = signInManager;
_userManager = userManager;
_applicationManager = applicationManager;
_authorizationManager = authorizationManager;
_scopeManager = scopeManager;
_logger = logger;
}
[HttpPost("login")]
public async Task<IActionResult> 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<LoginResponse> GenerateTokenAsync(ApplicationUser user)
{
var claims = new List<System.Security.Claims.Claim>
{
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"
};
}
}

View File

@ -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<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager,
ILogger<AuthorizationController> logger)
: Controller
{
[HttpGet("authorize")]
[HttpPost("authorize")]
public async Task<IActionResult> 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<string, string>
{
[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<string, string>
{
[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<string, string>
{
[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<string> 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;
}
}
}

View File

@ -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<DashboardController> _logger;
public DashboardController(ILogger<DashboardController> 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
});
}
}

View File

@ -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<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ILogger<LogoutController> _logger;
public LogoutController(
IOpenIddictApplicationManager applicationManager,
IOpenIddictAuthorizationManager authorizationManager,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ILogger<LogoutController> logger)
{
_applicationManager = applicationManager;
_authorizationManager = authorizationManager;
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
}
[HttpGet("endsession")]
[HttpPost("endsession")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> 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("/");
}
}

View File

@ -1,12 +1,15 @@
using Fengling.AuthService.Data; using Fengling.AuthService.Data;
using Fengling.AuthService.Models; using Fengling.AuthService.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace Fengling.AuthService.Controllers; namespace Fengling.AuthService.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize]
public class OAuthClientsController : ControllerBase public class OAuthClientsController : ControllerBase
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -21,9 +24,60 @@ public class OAuthClientsController : ControllerBase
} }
[HttpGet] [HttpGet]
public async Task<ActionResult<IEnumerable<OAuthApplication>>> GetClients() public async Task<ActionResult<object>> GetClients(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10,
[FromQuery] string? displayName = null,
[FromQuery] string? clientId = null,
[FromQuery] string? status = null)
{ {
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}")] [HttpGet("{id}")]
@ -34,27 +88,97 @@ public class OAuthClientsController : ControllerBase
{ {
return NotFound(); 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<ActionResult<object>> 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] [HttpPost]
public async Task<ActionResult<OAuthApplication>> CreateClient(OAuthApplication application) public async Task<ActionResult<OAuthApplication>> 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(); 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}")] [HttpPut("{id}")]
public async Task<IActionResult> UpdateClient(long id, OAuthApplication application) public async Task<IActionResult> 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 _context.SaveChangesAsync();
await CreateAuditLog("oauth", "update", "OAuthClient", client.Id, client.DisplayName, oldValue, SerializeToJson(client));
return NoContent(); return NoContent();
} }
@ -67,8 +191,73 @@ public class OAuthClientsController : ControllerBase
return NotFound(); return NotFound();
} }
var oldValue = SerializeToJson(client);
_context.OAuthApplications.Remove(client); _context.OAuthApplications.Remove(client);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
await CreateAuditLog("oauth", "delete", "OAuthClient", client.Id, client.DisplayName, oldValue);
return NoContent(); 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<string>();
public string[] PostLogoutRedirectUris { get; set; } = Array.Empty<string>();
public string[] Scopes { get; set; } = Array.Empty<string>();
public string[] GrantTypes { get; set; } = Array.Empty<string>();
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<string>();
public string[] PostLogoutRedirectUris { get; set; } = Array.Empty<string>();
public string[] Scopes { get; set; } = Array.Empty<string>();
public string[] GrantTypes { get; set; } = Array.Empty<string>();
public string ClientType { get; set; } = "confidential";
public string ConsentType { get; set; } = "implicit";
public string Status { get; set; } = "active";
public string? Description { get; set; }
} }

View File

@ -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<ApplicationRole> _roleManager;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<RolesController> _logger;
public RolesController(
ApplicationDbContext context,
RoleManager<ApplicationRole> roleManager,
UserManager<ApplicationUser> userManager,
ILogger<RolesController> logger)
{
_context = context;
_roleManager = roleManager;
_userManager = userManager;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<object>> 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<object>();
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<ActionResult<ApplicationRole>> 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<ActionResult<List<object>>> 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<ActionResult<ApplicationRole>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string> Permissions { get; set; } = new();
}
public class UpdateRoleDto
{
public string DisplayName { get; set; } = string.Empty;
public string? Description { get; set; }
public List<string> Permissions { get; set; } = new();
}

View File

@ -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<StatsController> _logger;
public StatsController(
ApplicationDbContext context,
ILogger<StatsController> logger)
{
_context = context;
_logger = logger;
}
[HttpGet("dashboard")]
public async Task<ActionResult<object>> 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<object> 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,
});
}
}

View File

@ -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<ApplicationUser> _userManager;
private readonly ILogger<TenantsController> _logger;
public TenantsController(
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
ILogger<TenantsController> logger)
{
_context = context;
_userManager = userManager;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<object>> 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<object>();
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<ActionResult<Tenant>> 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<ActionResult<List<object>>> 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<ActionResult<List<object>>> 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<ActionResult<TenantSettings>> 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<string> { "requireNumber", "requireLowercase" },
MinPasswordLength = 8,
SessionTimeout = 120,
};
return Ok(settings);
}
[HttpPut("{tenantId}/settings")]
public async Task<IActionResult> 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<ActionResult<Tenant>> 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<IActionResult> 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<IActionResult> 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<string> PasswordPolicy { get; set; } = new();
public int MinPasswordLength { get; set; } = 8;
public int SessionTimeout { get; set; } = 120;
}

View File

@ -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<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ILogger<TokenController> logger)
: ControllerBase
{
private readonly ILogger<TokenController> _logger = logger;
[HttpPost("token")]
public async Task<IActionResult> 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<IActionResult> 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<Claim>
{
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<IActionResult> 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<string, string>
{
[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<string, string>
{
[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<string> 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<IActionResult> 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<Claim>
{
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);
}
}

View File

@ -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<ApplicationUser> _userManager;
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly ILogger<UsersController> _logger;
public UsersController(
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
RoleManager<ApplicationRole> roleManager,
ILogger<UsersController> logger)
{
_context = context;
_userManager = userManager;
_roleManager = roleManager;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<ApplicationUser>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<long> 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;
}

View File

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

View File

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

View File

@ -12,6 +12,9 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, Applicati
} }
public DbSet<OAuthApplication> OAuthApplications { get; set; } public DbSet<OAuthApplication> OAuthApplications { get; set; }
public DbSet<Tenant> Tenants { get; set; }
public DbSet<AccessLog> AccessLogs { get; set; }
public DbSet<AuditLog> AuditLogs { get; set; }
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@ -40,6 +43,57 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, Applicati
entity.Property(e => e.ClientType).HasMaxLength(20); entity.Property(e => e.ClientType).HasMaxLength(20);
entity.Property(e => e.ConsentType).HasMaxLength(20); entity.Property(e => e.ConsentType).HasMaxLength(20);
entity.Property(e => e.Status).HasMaxLength(20); entity.Property(e => e.Status).HasMaxLength(20);
entity.Property(e => e.Description).HasMaxLength(500);
});
builder.Entity<Tenant>(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<AccessLog>(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<AuditLog>(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);
}); });
} }
} }

View File

@ -0,0 +1,621 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Duration")
.HasColumnType("integer");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Method")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("RequestData")
.HasColumnType("text");
b.Property<string>("Resource")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ResponseData")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TenantId")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.PrimitiveCollection<List<string>>("Permissions")
.HasColumnType("text[]");
b.Property<long?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("Phone")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("RealName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<long>("TenantId")
.HasColumnType("bigint");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("IpAddress")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NewValue")
.HasColumnType("text");
b.Property<string>("OldValue")
.HasColumnType("text");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Operator")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<long?>("TargetId")
.HasColumnType("bigint");
b.Property<string>("TargetName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("TargetType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ClientId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ClientSecret")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClientType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ConsentType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.PrimitiveCollection<string[]>("GrantTypes")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<string[]>("PostLogoutRedirectUris")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ContactEmail")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ContactName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int?>("MaxUsers")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("TenantId")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<long>("RoleId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<long>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<long>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<long>", b =>
{
b.Property<long>("UserId")
.HasColumnType("bigint");
b.Property<long>("RoleId")
.HasColumnType("bigint");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<long>", b =>
{
b.Property<long>("UserId")
.HasColumnType("bigint");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("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<long>", b =>
{
b.HasOne("Fengling.AuthService.Models.ApplicationRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<long>", b =>
{
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<long>", b =>
{
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<long>", 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<long>", 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
}
}
}

View File

@ -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
{
/// <inheritdoc />
public partial class AddTenantAndLogs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DisplayName",
table: "AspNetRoles",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsSystem",
table: "AspNetRoles",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<List<string>>(
name: "Permissions",
table: "AspNetRoles",
type: "text[]",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "TenantId",
table: "AspNetRoles",
type: "bigint",
nullable: true);
migrationBuilder.CreateTable(
name: "AccessLogs",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
TenantId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Action = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Resource = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Method = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
IpAddress = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
UserAgent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Duration = table.Column<int>(type: "integer", nullable: false),
RequestData = table.Column<string>(type: "text", nullable: true),
ResponseData = table.Column<string>(type: "text", nullable: true),
ErrorMessage = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(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<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Operator = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
TenantId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Operation = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Action = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
TargetType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
TargetId = table.Column<long>(type: "bigint", nullable: true),
TargetName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
IpAddress = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
OldValue = table.Column<string>(type: "text", nullable: true),
NewValue = table.Column<string>(type: "text", nullable: true),
ErrorMessage = table.Column<string>(type: "text", nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedAt = table.Column<DateTime>(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<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TenantId = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
ContactName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
ContactEmail = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
ContactPhone = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
MaxUsers = table.Column<int>(type: "integer", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsDeleted = table.Column<bool>(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);
}
/// <inheritdoc />
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");
}
}
}

View File

@ -0,0 +1,625 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Duration")
.HasColumnType("integer");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Method")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("RequestData")
.HasColumnType("text");
b.Property<string>("Resource")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ResponseData")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TenantId")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.PrimitiveCollection<List<string>>("Permissions")
.HasColumnType("text[]");
b.Property<long?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("Phone")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("RealName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<long>("TenantId")
.HasColumnType("bigint");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("IpAddress")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NewValue")
.HasColumnType("text");
b.Property<string>("OldValue")
.HasColumnType("text");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Operator")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<long?>("TargetId")
.HasColumnType("bigint");
b.Property<string>("TargetName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("TargetType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ClientId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ClientSecret")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClientType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ConsentType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.PrimitiveCollection<string[]>("GrantTypes")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<string[]>("PostLogoutRedirectUris")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ContactEmail")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ContactName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int?>("MaxUsers")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("TenantId")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<long>("RoleId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<long>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<long>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<long>", b =>
{
b.Property<long>("UserId")
.HasColumnType("bigint");
b.Property<long>("RoleId")
.HasColumnType("bigint");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<long>", b =>
{
b.Property<long>("UserId")
.HasColumnType("bigint");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("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<long>", b =>
{
b.HasOne("Fengling.AuthService.Models.ApplicationRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<long>", b =>
{
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<long>", b =>
{
b.HasOne("Fengling.AuthService.Models.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<long>", 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<long>", 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
}
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Fengling.AuthService.Data.Migrations
{
/// <inheritdoc />
public partial class AddOAuthDescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Description",
table: "OAuthApplications",
type: "character varying(500)",
maxLength: 500,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Description",
table: "OAuthApplications");
}
}
}

View File

@ -1,5 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using System.Collections.Generic;
using Fengling.AuthService.Data; using Fengling.AuthService.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@ -22,6 +23,78 @@ namespace Fengling.AuthService.Data.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Fengling.AuthService.Models.AccessLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Duration")
.HasColumnType("integer");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Method")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("RequestData")
.HasColumnType("text");
b.Property<string>("Resource")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ResponseData")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TenantId")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("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 => modelBuilder.Entity("Fengling.AuthService.Models.ApplicationRole", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -41,6 +114,12 @@ namespace Fengling.AuthService.Data.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name") b.Property<string>("Name")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
@ -49,6 +128,12 @@ namespace Fengling.AuthService.Data.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)"); .HasColumnType("character varying(256)");
b.PrimitiveCollection<List<string>>("Permissions")
.HasColumnType("text[]");
b.Property<long?>("TenantId")
.HasColumnType("bigint");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("NormalizedName") b.HasIndex("NormalizedName")
@ -150,6 +235,85 @@ namespace Fengling.AuthService.Data.Migrations
b.ToTable("AspNetUsers", (string)null); b.ToTable("AspNetUsers", (string)null);
}); });
modelBuilder.Entity("Fengling.AuthService.Models.AuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("IpAddress")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NewValue")
.HasColumnType("text");
b.Property<string>("OldValue")
.HasColumnType("text");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Operator")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<long?>("TargetId")
.HasColumnType("bigint");
b.Property<string>("TargetName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("TargetType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("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 => modelBuilder.Entity("Fengling.AuthService.Models.OAuthApplication", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -180,6 +344,10 @@ namespace Fengling.AuthService.Data.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("DisplayName") b.Property<string>("DisplayName")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@ -217,6 +385,70 @@ namespace Fengling.AuthService.Data.Migrations
b.ToTable("OAuthApplications"); b.ToTable("OAuthApplications");
}); });
modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ContactEmail")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ContactName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ContactPhone")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int?>("MaxUsers")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("TenantId")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -320,6 +552,15 @@ namespace Fengling.AuthService.Data.Migrations
b.ToTable("AspNetUserTokens", (string)null); 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<long>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<long>", b =>
{ {
b.HasOne("Fengling.AuthService.Models.ApplicationRole", null) b.HasOne("Fengling.AuthService.Models.ApplicationRole", null)
@ -370,6 +611,11 @@ namespace Fengling.AuthService.Data.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Fengling.AuthService.Models.Tenant", b =>
{
b.Navigation("Users");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View File

@ -15,18 +15,65 @@ public static class SeedData
context.Database.EnsureCreated(); 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"); var adminRole = await roleManager.FindByNameAsync("Admin");
if (adminRole == null) if (adminRole == null)
{ {
adminRole = new ApplicationRole adminRole = new ApplicationRole
{ {
Name = "Admin", Name = "Admin",
DisplayName = "管理员",
Description = "System administrator", Description = "System administrator",
TenantId = defaultTenant.Id,
IsSystem = true,
Permissions = new List<string>
{
"user.manage", "user.view",
"role.manage", "role.view",
"tenant.manage", "tenant.view",
"oauth.manage", "oauth.view",
"log.view", "system.config"
},
CreatedTime = DateTime.UtcNow CreatedTime = DateTime.UtcNow
}; };
await roleManager.CreateAsync(adminRole); 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<string> { "user.view" },
CreatedTime = DateTime.UtcNow
};
await roleManager.CreateAsync(userRole);
}
var adminUser = await userManager.FindByNameAsync("admin"); var adminUser = await userManager.FindByNameAsync("admin");
if (adminUser == null) if (adminUser == null)
{ {
@ -36,7 +83,7 @@ public static class SeedData
Email = "admin@fengling.local", Email = "admin@fengling.local",
RealName = "系统管理员", RealName = "系统管理员",
Phone = "13800138000", Phone = "13800138000",
TenantId = 1, TenantId = defaultTenant.Id,
EmailConfirmed = true, EmailConfirmed = true,
IsDeleted = false, IsDeleted = false,
CreatedTime = DateTime.UtcNow CreatedTime = DateTime.UtcNow
@ -58,7 +105,7 @@ public static class SeedData
Email = "test@fengling.local", Email = "test@fengling.local",
RealName = "测试用户", RealName = "测试用户",
Phone = "13900139000", Phone = "13900139000",
TenantId = 1, TenantId = defaultTenant.Id,
EmailConfirmed = true, EmailConfirmed = true,
IsDeleted = false, IsDeleted = false,
CreatedTime = DateTime.UtcNow CreatedTime = DateTime.UtcNow
@ -67,13 +114,6 @@ public static class SeedData
var result = await userManager.CreateAsync(testUser, "Test@123"); var result = await userManager.CreateAsync(testUser, "Test@123");
if (result.Succeeded) if (result.Succeeded)
{ {
var userRole = new ApplicationRole
{
Name = "User",
Description = "普通用户",
CreatedTime = DateTime.UtcNow
};
await roleManager.CreateAsync(userRole);
await userManager.AddToRoleAsync(testUser, "User"); await userManager.AddToRoleAsync(testUser, "User");
} }
} }

View File

@ -6,23 +6,30 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.0.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.2" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Npgsql" Version="8.0.2" /> <PackageReference Include="AspNetCore.HealthChecks.Npgsql" Version="9.0.0" />
<PackageReference Include="OpenIddict.AspNetCore" Version="7.2.0" /> <PackageReference Include="OpenIddict.AspNetCore" Version="7.2.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" /> <PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.2" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="OpenTelemetry" Version="1.11.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.11.0" /> <PackageReference Include="OpenTelemetry" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.11.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
</ItemGroup>
<ItemGroup>
<Content Update="appsettings.Testing.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
</Project> </Project>

43
Models/AccessLog.cs Normal file
View File

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

View File

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using System.ComponentModel.DataAnnotations.Schema;
namespace Fengling.AuthService.Models; namespace Fengling.AuthService.Models;
@ -6,4 +7,8 @@ public class ApplicationRole : IdentityRole<long>
{ {
public string? Description { get; set; } public string? Description { get; set; }
public DateTime CreatedTime { get; set; } = DateTime.UtcNow; public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
public long? TenantId { get; set; }
public bool IsSystem { get; set; }
public string? DisplayName { get; set; }
public List<string>? Permissions { get; set; }
} }

47
Models/AuditLog.cs Normal file
View File

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

View File

@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace Fengling.AuthService.Models; namespace Fengling.AuthService.Models;
public class OAuthApplication public class OAuthApplication
@ -13,6 +15,7 @@ public class OAuthApplication
public string ClientType { get; set; } = "public"; public string ClientType { get; set; } = "public";
public string ConsentType { get; set; } = "implicit"; public string ConsentType { get; set; } = "implicit";
public string Status { get; set; } = "active"; public string Status { get; set; } = "active";
public string? Description { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
} }

47
Models/Tenant.cs Normal file
View File

@ -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<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
}

View File

@ -1,9 +1,10 @@
using Fengling.AuthService.Configuration; using Fengling.AuthService.Configuration;
using Fengling.AuthService.Data; using Fengling.AuthService.Data;
using Fengling.AuthService.Models; using Fengling.AuthService.Models;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi;
using OpenTelemetry; using OpenTelemetry;
using OpenTelemetry.Resources; using OpenTelemetry.Resources;
using OpenTelemetry.Trace; using OpenTelemetry.Trace;
@ -19,13 +20,37 @@ Log.Logger = new LoggerConfiguration()
builder.Host.UseSerilog(); builder.Host.UseSerilog();
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options => builder.Services.AddDbContext<ApplicationDbContext>(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<ApplicationUser, ApplicationRole>() builder.Services.AddIdentity<ApplicationUser, ApplicationRole>()
.AddEntityFrameworkStores<ApplicationDbContext>() .AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders(); .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.AddOpenIddictConfiguration(builder.Configuration);
builder.Services.AddOpenTelemetry() builder.Services.AddOpenTelemetry()
@ -37,7 +62,7 @@ builder.Services.AddOpenTelemetry()
.AddSource("OpenIddict.Server.AspNetCore") .AddSource("OpenIddict.Server.AspNetCore")
.AddOtlpExporter()); .AddOtlpExporter());
builder.Services.AddControllers(); builder.Services.AddControllersWithViews();
builder.Services.AddHealthChecks() builder.Services.AddHealthChecks()
.AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection")!); .AddNpgSql(builder.Configuration.GetConnectionString("DefaultConnection")!);
@ -71,18 +96,24 @@ using (var scope = app.Services.CreateScope())
await SeedData.Initialize(scope.ServiceProvider); await SeedData.Initialize(scope.ServiceProvider);
} }
app.UseSwagger(); app.UseStaticFiles();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Fengling Auth Service v1");
options.OAuthClientId("swagger-ui");
options.OAuthUsePkce();
});
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
var isTesting = builder.Configuration.GetValue<bool>("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.MapControllers();
app.MapHealthChecks("/health"); app.MapHealthChecks("/health");

View File

@ -1,4 +1,4 @@
{ {
"$schema": "https://json.schemastore.org/launchsettings.json", "$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": { "profiles": {
"http": { "http": {
@ -9,15 +9,6 @@
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7150;http://localhost:5132",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
} }
} }
} }

View File

@ -0,0 +1,6 @@
namespace Fengling.AuthService.ViewModels;
public record AuthorizeViewModel(string? ApplicationName, string? Scope)
{
public string[]? Scopes => Scope?.Split(' ') ?? null;
}

View File

@ -0,0 +1,7 @@
namespace Fengling.AuthService.ViewModels;
public class DashboardViewModel
{
public string? Username { get; set; }
public string? Email { get; set; }
}

View File

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

View File

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

View File

@ -0,0 +1,81 @@
@model Fengling.AuthService.ViewModels.LoginInputModel
@{
Layout = "_Layout";
ViewData["Title"] = "登录";
}
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4 py-8">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mb-4">
<svg class="h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<h1 class="text-2xl font-bold">欢迎回来</h1>
<p class="text-muted-foreground mt-2">登录到 Fengling Auth</p>
</div>
<div class="bg-card border border-border rounded-lg shadow-sm p-6">
@if (!ViewData.ModelState.IsValid)
{
<div class="mb-4 p-3 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-sm">
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
{
<p>@error.ErrorMessage</p>
}
</div>
}
<form method="post" class="space-y-4">
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl" asp-for="ReturnUrl" />
<div class="space-y-2">
<label for="Username" class="text-sm font-medium">用户名</label>
<input type="text"
id="Username"
name="Username"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="请输入用户名"
value="@Model.Username"
required
autocomplete="username">
</div>
<div class="space-y-2">
<label for="Password" class="text-sm font-medium">密码</label>
<input type="password"
id="Password"
name="Password"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="请输入密码"
required
autocomplete="current-password">
</div>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<input type="checkbox"
id="RememberMe"
name="RememberMe"
class="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-ring focus:ring-offset-2">
<label for="RememberMe" class="text-sm font-medium">记住我</label>
</div>
</div>
<button type="submit"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bg-primary text-primary-foreground hover:bg-primary/90 h-11 w-full px-8 shadow">
登录
</button>
</form>
</div>
<div class="mt-6 text-center text-sm">
<span class="text-muted-foreground">还没有账号?</span>
<a href="/account/register?returnUrl=@Model.ReturnUrl" class="text-primary hover:underline ml-1">立即注册</a>
</div>
</div>
</div>

View File

@ -0,0 +1,95 @@
@model Fengling.AuthService.ViewModels.RegisterViewModel
@{
Layout = "_Layout";
ViewData["Title"] = "注册";
}
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4 py-8">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mb-4">
<svg class="h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
</div>
<h1 class="text-2xl font-bold">创建账号</h1>
<p class="text-muted-foreground mt-2">加入 Fengling Auth</p>
</div>
<div class="bg-card border border-border rounded-lg shadow-sm p-6">
@if (!ViewData.ModelState.IsValid)
{
<div class="mb-4 p-3 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-sm">
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
{
<p>@error.ErrorMessage</p>
}
</div>
}
<form method="post" class="space-y-4">
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl" asp-for="ReturnUrl" />
<div class="space-y-2">
<label for="Username" class="text-sm font-medium">用户名</label>
<input type="text"
id="Username"
name="Username"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="请输入用户名"
value="@Model.Username"
required
autocomplete="username">
</div>
<div class="space-y-2">
<label for="Email" class="text-sm font-medium">邮箱</label>
<input type="email"
id="Email"
name="Email"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="请输入邮箱地址"
value="@Model.Email"
required
autocomplete="email">
</div>
<div class="space-y-2">
<label for="Password" class="text-sm font-medium">密码</label>
<input type="password"
id="Password"
name="Password"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="请输入密码至少6个字符"
required
autocomplete="new-password">
</div>
<div class="space-y-2">
<label for="ConfirmPassword" class="text-sm font-medium">确认密码</label>
<input type="password"
id="ConfirmPassword"
name="ConfirmPassword"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="请再次输入密码"
required
autocomplete="new-password">
</div>
<button type="submit"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bg-primary text-primary-foreground hover:bg-primary/90 h-11 w-full px-8 shadow">
注册
</button>
</form>
</div>
<div class="mt-6 text-center text-sm">
<span class="text-muted-foreground">已有账号?</span>
<a href="/account/login?returnUrl=@Model.ReturnUrl" class="text-primary hover:underline ml-1">立即登录</a>
</div>
</div>
</div>

View File

@ -0,0 +1,146 @@
@model Fengling.AuthService.ViewModels.AuthorizeViewModel
@{
Layout = "_Layout";
ViewData["Title"] = "授权确认";
}
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4 py-8">
<div class="w-full max-w-md">
<!-- Header -->
<div class="text-center mb-8">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mb-4">
<svg class="h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<h1 class="text-2xl font-bold">授权确认</h1>
<p class="text-muted-foreground mt-2">
<span class="text-primary font-medium">@Model.ApplicationName</span>
请求访问您的账户
</p>
</div>
<!-- Authorization Card -->
<div class="bg-card border border-border rounded-lg shadow-sm overflow-hidden">
<!-- App Info Section -->
<div class="p-6 border-b border-border">
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<div class="h-12 w-12 rounded-lg bg-primary flex items-center justify-center text-primary-foreground text-lg font-semibold">
@(Model.ApplicationName?.Substring(0, Math.Min(1, Model.ApplicationName.Length)).ToUpper() ?? "A")
</div>
</div>
<div class="flex-1 min-w-0">
<h2 class="font-semibold text-lg truncate">@Model.ApplicationName</h2>
<p class="text-sm text-muted-foreground mt-1">
该应用将获得以下权限:
</p>
</div>
</div>
</div>
<!-- Permissions Section -->
<div class="p-6">
<h3 class="text-sm font-medium text-foreground mb-4">请求的权限</h3>
<div class="space-y-3">
@if (Model.Scopes != null && Model.Scopes.Length > 0)
{
@foreach (var scope in Model.Scopes)
{
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<svg class="h-5 w-5 text-primary mt-0.5 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<div class="flex-1">
<p class="text-sm font-medium">@GetScopeDisplayName(scope)</p>
<p class="text-xs text-muted-foreground mt-0.5">@GetScopeDescription(scope)</p>
</div>
</div>
}
}
else
{
<p class="text-sm text-muted-foreground">无特定权限请求</p>
}
</div>
</div>
<!-- Warning Section -->
<div class="p-4 bg-destructive/5 border-t border-border">
<div class="flex items-start gap-2">
<svg class="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
<path d="M12 9v4"/>
<path d="M12 17h.01"/>
</svg>
<p class="text-xs text-muted-foreground">
授予权限后,该应用将能够访问您的账户信息。您可以随时在授权管理中撤销权限。
</p>
</div>
</div>
</div>
<!-- Action Buttons -->
<form method="post" class="mt-6 space-y-3">
<div class="grid grid-cols-2 gap-3">
<button type="submit" name="action" value="accept"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 shadow">
<svg class="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
授权
</button>
<button type="submit" name="action" value="deny"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-11 px-8">
<svg class="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
取消
</button>
</div>
</form>
<!-- Footer Links -->
<div class="mt-6 text-center text-sm">
<a href="#" class="text-muted-foreground hover:text-foreground transition-colors">
了解关于 OAuth 授权的更多信息
</a>
</div>
</div>
</div>
@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" => "在您离线时仍可访问数据",
_ => "自定义权限范围"
};
}
}

View File

@ -0,0 +1,155 @@
@model Fengling.AuthService.ViewModels.DashboardViewModel
@{
Layout = "_Layout";
ViewData["Title"] = "控制台";
}
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold">欢迎,@Model.Username</h1>
<p class="text-muted-foreground mt-2">这里是您的控制台首页</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-card border border-border rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">已登录应用</p>
<p class="text-2xl font-bold mt-2">3</p>
</div>
<div class="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<svg class="h-6 w-6 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</div>
</div>
</div>
<div class="bg-card border border-border rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">授权次数</p>
<p class="text-2xl font-bold mt-2">12</p>
</div>
<div class="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center">
<svg class="h-6 w-6 text-green-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
</div>
</div>
<div class="bg-card border border-border rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">活跃会话</p>
<p class="text-2xl font-bold mt-2">5</p>
</div>
<div class="h-12 w-12 rounded-full bg-blue-500/10 flex items-center justify-center">
<svg class="h-6 w-6 text-blue-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
</div>
</div>
<div class="bg-card border border-border rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">安全评分</p>
<p class="text-2xl font-bold mt-2">92%</p>
</div>
<div class="h-12 w-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
<svg class="h-6 w-6 text-yellow-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 bg-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">最近活动</h2>
<div class="space-y-4">
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
<div class="h-10 w-10 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
F
</div>
<div class="flex-1">
<p class="text-sm font-medium">登录成功</p>
<p class="text-xs text-muted-foreground">通过 Fengling.Console.Web 登录</p>
</div>
<span class="text-xs text-muted-foreground">2分钟前</span>
</div>
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
<div class="h-10 w-10 rounded-full bg-green-600 flex items-center justify-center text-white text-sm font-medium">
</div>
<div class="flex-1">
<p class="text-sm font-medium">授权成功</p>
<p class="text-xs text-muted-foreground">授予 Fengling.Console.Web 访问权限</p>
</div>
<span class="text-xs text-muted-foreground">5分钟前</span>
</div>
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
<div class="h-10 w-10 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
🔄
</div>
<div class="flex-1">
<p class="text-sm font-medium">令牌刷新</p>
<p class="text-xs text-muted-foreground">刷新访问令牌</p>
</div>
<span class="text-xs text-muted-foreground">1小时前</span>
</div>
</div>
</div>
<div class="bg-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">快捷操作</h2>
<div class="space-y-3">
<a href="/profile" class="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted transition-colors">
<svg class="h-5 w-5 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span class="text-sm">个人资料</span>
</a>
<a href="/settings" class="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted transition-colors">
<svg class="h-5 w-5 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.1a2 2 0 0 1-1-1.72v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span class="text-sm">账户设置</span>
</a>
<a href="/connect/authorize" class="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted transition-colors">
<svg class="h-5 w-5 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<span class="text-sm">授权管理</span>
</a>
<form method="post" action="/account/logout">
<button type="submit" class="w-full flex items-center gap-3 p-3 rounded-lg border border-destructive/20 hover:bg-destructive/10 transition-colors text-left">
<svg class="h-5 w-5 text-destructive" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span class="text-sm text-destructive">退出登录</span>
</button>
</form>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,50 @@
@model Fengling.AuthService.ViewModels.DashboardViewModel
@{
Layout = "_Layout";
ViewData["Title"] = "个人资料";
}
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold">个人资料</h1>
<p class="text-muted-foreground mt-2">管理您的个人信息</p>
</div>
<div class="max-w-2xl">
<div class="bg-card border border-border rounded-lg p-6">
<div class="flex items-center gap-6 mb-6">
<div class="h-24 w-24 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-3xl font-bold">
@(Model.Username?.Substring(0, 1).ToUpper() ?? "U")
</div>
<div>
<h2 class="text-xl font-semibold">@Model.Username</h2>
<p class="text-muted-foreground">@Model.Email</p>
</div>
</div>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium">用户名</label>
<div class="p-3 rounded-md border border-input bg-muted/50 text-sm">
@Model.Username
</div>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">邮箱</label>
<div class="p-3 rounded-md border border-input bg-muted/50 text-sm">
@Model.Email
</div>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">注册时间</label>
<div class="p-3 rounded-md border border-input bg-muted/50 text-sm">
2026-01-15
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,90 @@
@model Fengling.AuthService.ViewModels.DashboardViewModel
@{
Layout = "_Layout";
ViewData["Title"] = "设置";
}
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold">账户设置</h1>
<p class="text-muted-foreground mt-2">管理您的账户设置和偏好</p>
</div>
<div class="max-w-2xl space-y-6">
<div class="bg-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">修改密码</h2>
<form method="post" class="space-y-4">
<div class="space-y-2">
<label for="currentPassword" class="text-sm font-medium">当前密码</label>
<input type="password"
id="currentPassword"
name="currentPassword"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="请输入当前密码">
</div>
<div class="space-y-2">
<label for="newPassword" class="text-sm font-medium">新密码</label>
<input type="password"
id="newPassword"
name="newPassword"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="请输入新密码至少6个字符">
</div>
<div class="space-y-2">
<label for="confirmPassword" class="text-sm font-medium">确认新密码</label>
<input type="password"
id="confirmPassword"
name="confirmPassword"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="请再次输入新密码">
</div>
<button type="submit"
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-8">
修改密码
</button>
</form>
</div>
<div class="bg-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">安全选项</h2>
<div class="space-y-4">
<div class="flex items-center justify-between p-4 rounded-lg border border-border">
<div>
<p class="font-medium">两步验证</p>
<p class="text-sm text-muted-foreground">为您的账户添加额外的安全保护</p>
</div>
<button class="inline-flex items-center justify-center rounded-md text-sm font-medium border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 px-4">
启用
</button>
</div>
<div class="flex items-center justify-between p-4 rounded-lg border border-border">
<div>
<p class="font-medium">登录通知</p>
<p class="text-sm text-muted-foreground">当有新设备登录时发送通知</p>
</div>
<button class="inline-flex items-center justify-center rounded-md text-sm font-medium border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 px-4">
配置
</button>
</div>
</div>
</div>
<div class="bg-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">危险区域</h2>
<div class="flex items-center justify-between p-4 rounded-lg border border-destructive/20">
<div>
<p class="font-medium text-destructive">删除账户</p>
<p class="text-sm text-muted-foreground">永久删除您的账户和所有数据</p>
</div>
<button class="inline-flex items-center justify-center rounded-md text-sm font-medium bg-destructive text-destructive-foreground hover:bg-destructive/90 h-9 px-4">
删除账户
</button>
</div>
</div>
</div>
</div>

162
Views/Shared/_Layout.cshtml Normal file
View File

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fengling 认证服务系统 - 提供安全可靠的身份认证和授权服务" />
<meta name="author" content="Fengling Team" />
<meta name="keywords" content="认证,授权,登录,注册,SSO,OAuth2" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>@ViewData["Title"] - Fengling Auth</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- shadcn Theme CSS -->
<link rel="stylesheet" href="~/css/styles.css" />
</head>
<body class="min-h-screen antialiased flex flex-col">
<!-- Header / Navigation -->
<header class="sticky top-0 z-50 w-full border-b border-border bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/60">
<div class="container mx-auto flex h-16 items-center justify-between px-4">
<!-- Logo and Brand -->
<div class="flex items-center gap-2">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<span class="text-lg font-bold">Fengling Auth</span>
</div>
<!-- Navigation Links -->
<nav class="hidden md:flex items-center gap-6 text-sm font-medium">
<a href="/" class="transition-colors hover:text-foreground text-foreground">首页</a>
<a href="/dashboard" class="transition-colors hover:text-foreground text-muted-foreground">控制台</a>
<a href="/docs" class="transition-colors hover:text-foreground text-muted-foreground">文档</a>
<a href="/api" class="transition-colors hover:text-foreground text-muted-foreground">API</a>
</nav>
<!-- User Actions -->
<div class="flex items-center gap-4">
@if (User.Identity?.IsAuthenticated == true)
{
<!-- User Menu (Logged In) -->
<div class="relative group">
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted transition-colors">
<div class="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
@(User.Identity.Name?.Substring(0, 1).ToUpper() ?? "U")
</div>
<span class="hidden sm:inline">@User.Identity.Name</span>
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
<!-- Dropdown Menu -->
<div class="absolute right-0 top-full mt-1 w-48 rounded-md border border-border bg-white shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all">
<div class="p-2">
<a href="/profile" class="block rounded-sm px-3 py-2 text-sm hover:bg-muted transition-colors">个人资料</a>
<a href="/settings" class="block rounded-sm px-3 py-2 text-sm hover:bg-muted transition-colors">设置</a>
<hr class="my-2 border-border">
<a href="/logout" class="block rounded-sm px-3 py-2 text-sm hover:bg-muted transition-colors text-red-600">退出登录</a>
</div>
</div>
</div>
}
else
{
<!-- Login/Register Buttons (Guest) -->
<a href="/login" class="text-sm font-medium hover:text-foreground text-muted-foreground transition-colors">登录</a>
<a href="/register" class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2">
注册
</a>
}
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-1">
@RenderBody()
</main>
<!-- Footer -->
<footer class="border-t border-border bg-muted/50">
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand Column -->
<div class="space-y-3">
<div class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<span class="font-bold">Fengling Auth</span>
</div>
<p class="text-sm text-muted-foreground">
提供安全可靠的身份认证和授权服务,帮助企业快速实现用户管理和权限控制。
</p>
</div>
<!-- Product Column -->
<div class="space-y-3">
<h3 class="text-sm font-semibold">产品</h3>
<ul class="space-y-2 text-sm text-muted-foreground">
<li><a href="#" class="hover:text-foreground transition-colors">功能特性</a></li>
<li><a href="#" class="hover:text-foreground transition-colors">定价方案</a></li>
<li><a href="#" class="hover:text-foreground transition-colors">集成文档</a></li>
<li><a href="#" class="hover:text-foreground transition-colors">更新日志</a></li>
</ul>
</div>
<!-- Resources Column -->
<div class="space-y-3">
<h3 class="text-sm font-semibold">资源</h3>
<ul class="space-y-2 text-sm text-muted-foreground">
<li><a href="#" class="hover:text-foreground transition-colors">文档中心</a></li>
<li><a href="#" class="hover:text-foreground transition-colors">API 参考</a></li>
<li><a href="#" class="hover:text-foreground transition-colors">SDK 下载</a></li>
<li><a href="#" class="hover:text-foreground transition-colors">常见问题</a></li>
</ul>
</div>
<!-- Company Column -->
<div class="space-y-3">
<h3 class="text-sm font-semibold">关于</h3>
<ul class="space-y-2 text-sm text-muted-foreground">
<li><a href="#" class="hover:text-foreground transition-colors">关于我们</a></li>
<li><a href="#" class="hover:text-foreground transition-colors">联系方式</a></li>
<li><a href="#" class="hover:text-foreground transition-colors">隐私政策</a></li>
<li><a href="#" class="hover:text-foreground transition-colors">服务条款</a></li>
</ul>
</div>
</div>
<!-- Bottom Bar -->
<div class="mt-8 pt-8 border-t border-border">
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<p class="text-sm text-muted-foreground">
© 2026 Fengling Team. All rights reserved.
</p>
<div class="flex items-center gap-4">
<a href="#" class="text-muted-foreground hover:text-foreground transition-colors">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
</a>
<a href="#" class="text-muted-foreground hover:text-foreground transition-colors">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
</div>
</div>
</div>
</div>
</footer>
</body>
</html>

View File

@ -0,0 +1,3 @@
@using Fengling.AuthService
@using Fengling.AuthService.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

3
Views/_ViewStart.cshtml Normal file
View File

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

22
appsettings.Testing.json Normal file
View File

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

View File

@ -2,6 +2,11 @@
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=192.168.100.10;Port=5432;Database=fengling_auth;Username=movingsam;Password=sl52788542" "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": { "OpenIddict": {
"Issuer": "https://auth.fengling.local", "Issuer": "https://auth.fengling.local",
"Audience": "fengling-api" "Audience": "fengling-api"

210
wwwroot/css/styles.css Normal file
View File

@ -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);
}

193
wwwroot/login.html Normal file
View File

@ -0,0 +1,193 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - 风铃认证服务</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
background: white;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
width: 100%;
max-width: 400px;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 24px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: #667eea;
}
.remember-me {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.remember-me input {
margin-right: 8px;
}
button {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
font-weight: 600;
}
button:hover {
opacity: 0.9;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
background: #fee;
color: #c33;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
font-size: 14px;
}
.loading {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s ease-in-out infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<h1>风铃认证服务</h1>
<div id="error-message" class="error" style="display: none;"></div>
<form id="login-form">
<input type="hidden" id="returnUrl" name="returnUrl">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<div class="remember-me">
<input type="checkbox" id="rememberMe" name="rememberMe">
<label for="rememberMe" style="margin-bottom: 0;">记住我</label>
</div>
<button type="submit" id="submit-btn">
<span id="btn-text">登录</span>
</button>
</form>
</div>
<script>
const form = document.getElementById('login-form');
const errorElement = document.getElementById('error-message');
const submitBtn = document.getElementById('submit-btn');
const btnText = document.getElementById('btn-text');
// Get returnUrl from query parameter
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get('returnUrl') || '/';
document.getElementById('returnUrl').value = returnUrl;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
// Show loading state
submitBtn.disabled = true;
btnText.innerHTML = '<span class="loading"></span>登录中...';
try {
const response = await fetch('/account/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username,
password: password,
rememberMe: rememberMe,
returnUrl: returnUrl
})
});
const data = await response.json();
if (response.ok) {
// Redirect to returnUrl
window.location.href = data.returnUrl;
} else {
// Show error
errorElement.textContent = data.error || '登录失败,请重试';
errorElement.style.display = 'block';
// Reset button
submitBtn.disabled = false;
btnText.textContent = '登录';
}
} catch (error) {
errorElement.textContent = '网络错误,请重试';
errorElement.style.display = 'block';
// Reset button
submitBtn.disabled = false;
btnText.textContent = '登录';
}
});
</script>
</body>
</html>