diff --git a/Avalonia-API/Authentication/ApiAuthEndpointService.cs b/Avalonia-API/Authentication/ApiAuthEndpointService.cs new file mode 100644 index 0000000..a319352 --- /dev/null +++ b/Avalonia-API/Authentication/ApiAuthEndpointService.cs @@ -0,0 +1,123 @@ +using Avalonia_Common.Core; +using Avalonia_EFCore.Database; +using Avalonia_EFCore.Models; +using Avalonia_Services.Core; +using Avalonia_Services.Services.AuthService; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; + +namespace Avalonia_API.Authentication +{ + public sealed class ApiAuthEndpointService( + AppDataContext db, + JwtTokenService jwtTokenService, + RefreshTokenService refreshTokenService) : IApiAuthEndpointService + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public async Task LoginAsync(ServiceEndpointContext ctx) + { + var request = Deserialize(ctx.Body); + if (string.IsNullOrWhiteSpace(request?.Account)) + { + ctx.StatusCode = 400; + return ResponseHelper.Failure(400, "账号不能为空"); + } + + var user = await db.Users.FirstOrDefaultAsync( + x => x.Email == request.Account || x.Name == request.Account); + + if (user is null) + { + user = new UserEntity + { + Name = request.Account, + Email = request.Account.Contains('@') ? request.Account : null, + }; + db.Users.Add(user); + await db.SaveChangesAsync(); + } + + var roles = NormalizeRoles(request.Roles); + var accessToken = jwtTokenService.CreateAccessToken(user, roles); + var refreshToken = await refreshTokenService.CreateAsync( + user.Id, + ctx.GetHeader("User-Agent"), + GetRemoteIpAddress(ctx)); + + return ResponseHelper.Ok(new AuthTokenResponse( + accessToken.Token, + refreshToken.Token, + accessToken.ExpiresAt, + refreshToken.Entity.ExpiresAt, + roles), "登录成功"); + } + + public async Task RefreshAsync(ServiceEndpointContext ctx) + { + var request = Deserialize(ctx.Body); + var rotated = await refreshTokenService.RotateAsync( + request?.RefreshToken, + ctx.GetHeader("User-Agent"), + GetRemoteIpAddress(ctx)); + + if (rotated is null) + { + ctx.StatusCode = 401; + return ResponseHelper.Failure(401, "刷新 token 无效或已过期"); + } + + var user = await db.Users.FindAsync(rotated.Value.Entity.UserId); + if (user is null) + { + ctx.StatusCode = 401; + return ResponseHelper.Failure(401, "用户不存在"); + } + + var roles = new[] { "Admin" }; + var accessToken = jwtTokenService.CreateAccessToken(user, roles); + + return ResponseHelper.Ok(new AuthTokenResponse( + accessToken.Token, + rotated.Value.Token, + accessToken.ExpiresAt, + rotated.Value.Entity.ExpiresAt, + roles), "刷新成功"); + } + + public async Task LogoutAsync(ServiceEndpointContext ctx) + { + var request = Deserialize(ctx.Body); + await refreshTokenService.RevokeAsync(request?.RefreshToken); + return ResponseHelper.Succeed("退出成功"); + } + + private static T? Deserialize(string? body) + { + return string.IsNullOrWhiteSpace(body) + ? default + : JsonSerializer.Deserialize(body, JsonOptions); + } + + private static string? GetRemoteIpAddress(ServiceEndpointContext ctx) + { + return ctx.Items.TryGetValue("HttpContext", out var value) && value is HttpContext httpContext + ? httpContext.Connection.RemoteIpAddress?.ToString() + : null; + } + + private static string[] NormalizeRoles(string[]? roles) + { + var normalized = roles? + .Where(role => !string.IsNullOrWhiteSpace(role)) + .Select(role => role.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return normalized is { Length: > 0 } ? normalized : ["Admin"]; + } + } +} diff --git a/Avalonia-API/Authentication/JwtOptions.cs b/Avalonia-API/Authentication/JwtOptions.cs new file mode 100644 index 0000000..435bc28 --- /dev/null +++ b/Avalonia-API/Authentication/JwtOptions.cs @@ -0,0 +1,15 @@ +namespace Avalonia_API.Authentication +{ + public sealed class JwtOptions + { + public string Issuer { get; set; } = "Avalonia-API"; + + public string Audience { get; set; } = "Avalonia-Client"; + + public string SigningKey { get; set; } = "change-this-development-signing-key-at-least-32-bytes"; + + public int AccessTokenMinutes { get; set; } = 60; + + public int RefreshTokenDays { get; set; } = 30; + } +} diff --git a/Avalonia-API/Authentication/JwtTokenService.cs b/Avalonia-API/Authentication/JwtTokenService.cs new file mode 100644 index 0000000..023529c --- /dev/null +++ b/Avalonia-API/Authentication/JwtTokenService.cs @@ -0,0 +1,43 @@ +using Avalonia_EFCore.Models; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace Avalonia_API.Authentication +{ + public sealed class JwtTokenService(IOptions options) + { + private readonly JwtOptions _options = options.Value; + + public (string Token, DateTime ExpiresAt) CreateAccessToken(UserEntity user, IReadOnlyCollection roles) + { + var expiresAt = DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes); + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.Name ?? user.Email ?? $"user-{user.Id}"), + new("auth_type", "api-jwt"), + }; + + foreach (var role in roles.Where(role => !string.IsNullOrWhiteSpace(role)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)); + var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); + var jwt = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + notBefore: DateTime.UtcNow, + expires: expiresAt, + signingCredentials: credentials); + + return (new JwtSecurityTokenHandler().WriteToken(jwt), expiresAt); + } + } +} diff --git a/Avalonia-API/Authentication/RefreshTokenService.cs b/Avalonia-API/Authentication/RefreshTokenService.cs new file mode 100644 index 0000000..40cfbcc --- /dev/null +++ b/Avalonia-API/Authentication/RefreshTokenService.cs @@ -0,0 +1,84 @@ +using Avalonia_EFCore.Database; +using Avalonia_EFCore.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; + +namespace Avalonia_API.Authentication +{ + public sealed class RefreshTokenService(AppDataContext db, IOptions options) + { + private readonly JwtOptions _options = options.Value; + + public async Task<(string Token, ApiRefreshTokenEntity Entity)> CreateAsync( + int userId, + string? device, + string? ipAddress, + CancellationToken cancellationToken = default) + { + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + var entity = new ApiRefreshTokenEntity + { + UserId = userId, + TokenHash = HashToken(token), + ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenDays), + Device = device, + IpAddress = ipAddress, + }; + + db.ApiRefreshTokens.Add(entity); + await db.SaveChangesAsync(cancellationToken); + return (token, entity); + } + + public async Task FindActiveAsync(string? token, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + var hash = HashToken(token); + var entity = await db.ApiRefreshTokens.FirstOrDefaultAsync(x => x.TokenHash == hash, cancellationToken); + return entity?.IsActive == true ? entity : null; + } + + public async Task RevokeAsync(string? token, CancellationToken cancellationToken = default) + { + var entity = await FindActiveAsync(token, cancellationToken); + if (entity is null) + { + return; + } + + entity.RevokedAt = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + } + + public async Task<(string Token, ApiRefreshTokenEntity Entity)?> RotateAsync( + string? token, + string? device, + string? ipAddress, + CancellationToken cancellationToken = default) + { + var current = await FindActiveAsync(token, cancellationToken); + if (current is null) + { + return null; + } + + var next = await CreateAsync(current.UserId, device, ipAddress, cancellationToken); + current.RevokedAt = DateTime.UtcNow; + current.ReplacedByTokenHash = next.Entity.TokenHash; + await db.SaveChangesAsync(cancellationToken); + return next; + } + + private static string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(bytes); + } + } +} diff --git a/Avalonia-API/Avalonia-API.csproj b/Avalonia-API/Avalonia-API.csproj index 11e285e..5d22a45 100644 --- a/Avalonia-API/Avalonia-API.csproj +++ b/Avalonia-API/Avalonia-API.csproj @@ -9,11 +9,13 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Avalonia-API/Configuration/ServicesConfiguration.cs b/Avalonia-API/Configuration/ServicesConfiguration.cs index cced9cf..d90e564 100644 --- a/Avalonia-API/Configuration/ServicesConfiguration.cs +++ b/Avalonia-API/Configuration/ServicesConfiguration.cs @@ -1,9 +1,12 @@ -using Avalonia_EFCore.Database; +using Avalonia_API.Authentication; +using Avalonia_EFCore.Database; using Avalonia_Services.Core; using Avalonia_Services.Endpoints; using Avalonia_Services.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; +using Avalonia_Services.Services.AuthService; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; namespace Avalonia_API.Configuration { @@ -31,9 +34,35 @@ namespace Avalonia_API.Configuration // ---- 业务服务 ---- services.AddScoped(); + // ---- API 鉴权 ---- + var jwtSection = configuration.GetSection("Jwt"); + services.Configure(jwtSection); + var jwtOptions = jwtSection.Get() ?? new JwtOptions(); + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtOptions.Issuer, + ValidAudience = jwtOptions.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)), + ClockSkew = TimeSpan.FromMinutes(1), + }; + }); + services.AddAuthorization(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // ---- 统一端点 ---- var endpointBuilder = new ServiceEndpointBuilder(); AppEndpoints.Configure(endpointBuilder); + AuthEndpoints.ConfigureApi(endpointBuilder); var endpoints = endpointBuilder.Build(); services.AddSingleton(endpoints); diff --git a/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs b/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs index f43482f..b09c1d8 100644 --- a/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs +++ b/Avalonia-API/Extensions/UnifiedEndpointExtensions.cs @@ -1,4 +1,5 @@ using Avalonia_Services.Core; +using Microsoft.AspNetCore.Authorization; using AspNetCoreFilterContext = Microsoft.AspNetCore.Http.EndpointFilterInvocationContext; using AspNetCoreFilterDelegate = Microsoft.AspNetCore.Http.EndpointFilterDelegate; // 解决与 ASP.NET Core 同名类型的冲突 @@ -22,7 +23,7 @@ namespace Avalonia_API.Extensions { var apiGroup = routeBuilder.MapGroup("/"); - foreach (var endpoint in endpoints.Endpoints) + foreach (var endpoint in endpoints.ForHost(EndpointHostTarget.Api)) { var routeHandlerBuilder = MapEndpoint(apiGroup, endpoint, serviceProvider); @@ -43,7 +44,14 @@ namespace Avalonia_API.Extensions // 鉴权(使用 ASP.NET Core 原生鉴权机制) if (endpoint.RequireAuthorization) { - if (!string.IsNullOrEmpty(endpoint.Policy)) + if (endpoint.Roles.Count > 0) + { + routeHandlerBuilder.RequireAuthorization(new AuthorizeAttribute + { + Roles = string.Join(',', endpoint.Roles), + }); + } + else if (!string.IsNullOrEmpty(endpoint.Policy)) { routeHandlerBuilder.RequireAuthorization(endpoint.Policy); } @@ -57,6 +65,31 @@ namespace Avalonia_API.Extensions { routeHandlerBuilder.WithName(endpoint.Name); } + + if (!string.IsNullOrEmpty(endpoint.OpenApiTag)) + { + routeHandlerBuilder.WithTags(endpoint.OpenApiTag); + } + + if (!string.IsNullOrEmpty(endpoint.OpenApiDescription)) + { + routeHandlerBuilder.WithDescription(endpoint.OpenApiDescription); + } + + if (!string.IsNullOrWhiteSpace(endpoint.OpenApiSummary)) + { + routeHandlerBuilder.WithSummary(endpoint.OpenApiSummary); + } + + if (endpoint.OpenApiRequestType is not null) + { + routeHandlerBuilder.Accepts(endpoint.OpenApiRequestType, "application/json"); + } + + if (endpoint.OpenApiResponseType is not null) + { + routeHandlerBuilder.Produces(200, endpoint.OpenApiResponseType, "application/json"); + } } return routeBuilder; @@ -87,6 +120,7 @@ namespace Avalonia_API.Extensions { var ctx = await BuildContextFromHttpContext(httpContext); ctx.Items["ServiceProvider"] = serviceProvider; + ctx.Items["User"] = httpContext.User; var result = await unifiedHandler(ctx); @@ -127,6 +161,7 @@ namespace Avalonia_API.Extensions } ctx.Items["HttpContext"] = httpContext; + ctx.Items["User"] = httpContext.User; return ctx; } diff --git a/Avalonia-API/Program.cs b/Avalonia-API/Program.cs index 554dcb7..506a365 100644 --- a/Avalonia-API/Program.cs +++ b/Avalonia-API/Program.cs @@ -28,17 +28,21 @@ try // 初始化数据库(自动迁移 + 种子数据) app.Services.InitializeDatabase(); - // 启动时打印所有接口 var endpoints = app.Services.GetRequiredService(); - EndpointPrinter.PrintEndpoints(endpoints, "Avalonia-API 接口列表"); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapOpenApi(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/openapi/v1.json", "Avalonia API v1"); + options.RoutePrefix = "swagger"; + }); } app.UseHttpsRedirection(); + app.UseAuthentication(); app.UseAuthorization(); // 将统一端点映射到 ASP.NET Core 路由 diff --git a/Avalonia-API/appsettings.json b/Avalonia-API/appsettings.json index e0c98cf..245c998 100644 --- a/Avalonia-API/appsettings.json +++ b/Avalonia-API/appsettings.json @@ -6,6 +6,13 @@ } }, "AllowedHosts": "*", + "Jwt": { + "Issuer": "Avalonia-API", + "Audience": "Avalonia-Client", + "SigningKey": "change-this-development-signing-key-at-least-32-bytes", + "AccessTokenMinutes": 60, + "RefreshTokenDays": 30 + }, "DatabaseConfiguration": { "Provider": "SQLite", "ConnectionString": "Data Source=avalonia-api.db", diff --git a/Avalonia-EFCore/Database/AppDataContext.cs b/Avalonia-EFCore/Database/AppDataContext.cs index a01a194..0fd0426 100644 --- a/Avalonia-EFCore/Database/AppDataContext.cs +++ b/Avalonia-EFCore/Database/AppDataContext.cs @@ -16,6 +16,9 @@ namespace Avalonia_EFCore.Database /// 用户数据 public DbSet Users => Set(); + /// API refresh token 数据 + public DbSet ApiRefreshTokens => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -31,6 +34,13 @@ namespace Avalonia_EFCore.Database entity.HasKey(e => e.Id).HasName("pk-user"); entity.Property(e => e.Email).HasMaxLength(200); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("pk-api-refresh-token"); + entity.HasIndex(e => e.TokenHash).IsUnique().HasDatabaseName("idx-api-refresh-token-hash"); + entity.HasIndex(e => e.UserId).HasDatabaseName("idx-api-refresh-token-user-id"); + }); } } } diff --git a/Avalonia-EFCore/Migrations/20260515085847_AutoMigration_20260515165835.Designer.cs b/Avalonia-EFCore/Migrations/20260515085847_AutoMigration_20260515165835.Designer.cs new file mode 100644 index 0000000..389ebd2 --- /dev/null +++ b/Avalonia-EFCore/Migrations/20260515085847_AutoMigration_20260515165835.Designer.cs @@ -0,0 +1,173 @@ +// +using System; +using Avalonia_EFCore.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Avalonia_EFCore.Migrations +{ + [DbContext(typeof(AppDataContext))] + [Migration("20260515085847_AutoMigration_20260515165835")] + partial class AutoMigration_20260515165835 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at"); + + b.Property("Device") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("device"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT") + .HasColumnName("expires-at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("ip-address"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("replaced-by-token-hash"); + + b.Property("RevokedAt") + .HasColumnType("TEXT") + .HasColumnName("revoked-at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("token-hash"); + + b.Property("UserId") + .HasColumnType("INTEGER") + .HasColumnName("user-id"); + + b.HasKey("Id") + .HasName("pk-api-refresh-token"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("idx-api-refresh-token-hash"); + + b.HasIndex("UserId") + .HasDatabaseName("idx-api-refresh-token-user-id"); + + b.ToTable("api-refresh-token", t => + { + t.HasComment("API refresh token"); + }); + }); + + modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasComment("用户主键"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("email") + .HasComment("用户邮箱"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name") + .HasComment("用户名称"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("phone-number") + .HasComment("电话号码"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-user"); + + b.ToTable("user", t => + { + t.HasComment("用户实体,演示数据库 CRUD 操作"); + }); + }); + + modelBuilder.Entity("Avalonia_EFCore.Models.WeatherForecastEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasComment("天气预报主键"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at") + .HasComment("创建时间"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date") + .HasComment("预报日期"); + + b.Property("Summary") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("summary") + .HasComment("天气摘要"); + + b.Property("TemperatureC") + .HasColumnType("INTEGER") + .HasColumnName("temperature-c") + .HasComment("摄氏温度"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated-at") + .HasComment("更新时间"); + + b.HasKey("Id") + .HasName("pk-weather-forecast"); + + b.ToTable("weather-forecast", t => + { + t.HasComment("天气预报数据实体"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Avalonia-EFCore/Migrations/20260515085847_AutoMigration_20260515165835.cs b/Avalonia-EFCore/Migrations/20260515085847_AutoMigration_20260515165835.cs new file mode 100644 index 0000000..3678094 --- /dev/null +++ b/Avalonia-EFCore/Migrations/20260515085847_AutoMigration_20260515165835.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Avalonia_EFCore.Migrations +{ + /// + public partial class AutoMigration_20260515165835 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "api-refresh-token", + columns: table => new + { + id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + userid = table.Column(name: "user-id", type: "INTEGER", nullable: false), + tokenhash = table.Column(name: "token-hash", type: "TEXT", maxLength: 128, nullable: false), + createdat = table.Column(name: "created-at", type: "TEXT", nullable: false), + expiresat = table.Column(name: "expires-at", type: "TEXT", nullable: false), + revokedat = table.Column(name: "revoked-at", type: "TEXT", nullable: true), + replacedbytokenhash = table.Column(name: "replaced-by-token-hash", type: "TEXT", maxLength: 128, nullable: true), + device = table.Column(type: "TEXT", maxLength: 200, nullable: true), + ipaddress = table.Column(name: "ip-address", type: "TEXT", maxLength: 64, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk-api-refresh-token", x => x.id); + }, + comment: "API refresh token"); + + migrationBuilder.CreateIndex( + name: "idx-api-refresh-token-hash", + table: "api-refresh-token", + column: "token-hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "idx-api-refresh-token-user-id", + table: "api-refresh-token", + column: "user-id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "api-refresh-token"); + } + } +} diff --git a/Avalonia-EFCore/Migrations/AppDataContextModelSnapshot.cs b/Avalonia-EFCore/Migrations/AppDataContextModelSnapshot.cs index 13ded97..84f508d 100644 --- a/Avalonia-EFCore/Migrations/AppDataContextModelSnapshot.cs +++ b/Avalonia-EFCore/Migrations/AppDataContextModelSnapshot.cs @@ -17,6 +17,66 @@ namespace Avalonia_EFCore.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + modelBuilder.Entity("Avalonia_EFCore.Models.ApiRefreshTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created-at"); + + b.Property("Device") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("device"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT") + .HasColumnName("expires-at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("ip-address"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("replaced-by-token-hash"); + + b.Property("RevokedAt") + .HasColumnType("TEXT") + .HasColumnName("revoked-at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("token-hash"); + + b.Property("UserId") + .HasColumnType("INTEGER") + .HasColumnName("user-id"); + + b.HasKey("Id") + .HasName("pk-api-refresh-token"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("idx-api-refresh-token-hash"); + + b.HasIndex("UserId") + .HasDatabaseName("idx-api-refresh-token-user-id"); + + b.ToTable("api-refresh-token", t => + { + t.HasComment("API refresh token"); + }); + }); + modelBuilder.Entity("Avalonia_EFCore.Models.UserEntity", b => { b.Property("Id") diff --git a/Avalonia-EFCore/Models/ApiRefreshTokenEntity.cs b/Avalonia-EFCore/Models/ApiRefreshTokenEntity.cs new file mode 100644 index 0000000..97d333d --- /dev/null +++ b/Avalonia-EFCore/Models/ApiRefreshTokenEntity.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Avalonia_EFCore.Models +{ + /// + /// API refresh token。只保存哈希,不保存明文 token。 + /// + [Comment("API refresh token")] + [Table("api-refresh-token")] + public class ApiRefreshTokenEntity + { + [Key] + [Column("id")] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Column("user-id")] + public int UserId { get; set; } + + [Column("token-hash")] + [MaxLength(128)] + public string TokenHash { get; set; } = string.Empty; + + [Column("created-at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Column("expires-at")] + public DateTime ExpiresAt { get; set; } + + [Column("revoked-at")] + public DateTime? RevokedAt { get; set; } + + [Column("replaced-by-token-hash")] + [MaxLength(128)] + public string? ReplacedByTokenHash { get; set; } + + [Column("device")] + [MaxLength(200)] + public string? Device { get; set; } + + [Column("ip-address")] + [MaxLength(64)] + public string? IpAddress { get; set; } + + public bool IsActive => RevokedAt is null && ExpiresAt > DateTime.UtcNow; + } +} diff --git a/Avalonia-PC/Authentication/DefaultPcThirdPartyAuthorizationClient.cs b/Avalonia-PC/Authentication/DefaultPcThirdPartyAuthorizationClient.cs new file mode 100644 index 0000000..d4f6285 --- /dev/null +++ b/Avalonia-PC/Authentication/DefaultPcThirdPartyAuthorizationClient.cs @@ -0,0 +1,38 @@ +using Avalonia_Services.Services.AuthService; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Avalonia_PC.Authentication +{ + /// + /// 第三方授权客户端占位实现。接入真实第三方接口时替换此服务即可。 + /// + public sealed class DefaultPcThirdPartyAuthorizationClient : IPcThirdPartyAuthorizationClient + { + public Task ValidateAuthorizationCodeAsync( + string authorizationCode, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(authorizationCode) || + string.Equals(authorizationCode, "invalid", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(ThirdPartyAuthCheckResult.AuthorizationLost); + } + + return Task.FromResult(ThirdPartyAuthCheckResult.Valid); + } + + public Task RefreshAuthorizationAsync( + string authorizationReference, + CancellationToken cancellationToken = default) + { + if (string.Equals(authorizationReference, "invalid", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(ThirdPartyAuthCheckResult.AuthorizationLost); + } + + return Task.FromResult(ThirdPartyAuthCheckResult.TemporaryFailure); + } + } +} diff --git a/Avalonia-PC/Authentication/PcAuthEndpointService.cs b/Avalonia-PC/Authentication/PcAuthEndpointService.cs new file mode 100644 index 0000000..cff1dbf --- /dev/null +++ b/Avalonia-PC/Authentication/PcAuthEndpointService.cs @@ -0,0 +1,73 @@ +using Authentication; +using Avalonia_Common.Core; +using Avalonia_Services.Core; +using Avalonia_Services.Services.AuthService; +using System; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Avalonia_PC.Authentication +{ + public sealed class PcAuthEndpointService(PcGlobalTokenService tokenService) : IPcAuthEndpointService + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public async Task AuthorizeAsync(ServiceEndpointContext ctx) + { + var request = Deserialize(ctx.Body); + var token = await tokenService.AuthorizeAsync(request?.AuthorizationCode); + if (token is null) + { + ctx.StatusCode = 401; + return ResponseHelper.Failure(401, "授权失败"); + } + + return ResponseHelper.Ok(token, "授权成功"); + } + + public async Task RefreshAsync(ServiceEndpointContext ctx) + { + var request = Deserialize(ctx.Body); + var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization")); + var refreshed = await tokenService.RefreshAsync(token); + if (refreshed is null) + { + ctx.StatusCode = 401; + return ResponseHelper.Failure(401, "授权已失效"); + } + + return ResponseHelper.Ok(refreshed, "刷新成功"); + } + + public Task LogoutAsync(ServiceEndpointContext ctx) + { + var request = Deserialize(ctx.Body); + var token = request?.Token ?? ExtractBearerToken(ctx.GetHeader("Authorization")); + tokenService.Logout(token); + return Task.FromResult(ResponseHelper.Succeed("退出成功")); + } + + private static T? Deserialize(string? body) + { + return string.IsNullOrWhiteSpace(body) + ? default + : JsonSerializer.Deserialize(body, JsonOptions); + } + + private static string? ExtractBearerToken(string? authorization) + { + if (string.IsNullOrWhiteSpace(authorization)) + { + return null; + } + + const string prefix = "Bearer "; + return authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? authorization[prefix.Length..].Trim() + : authorization.Trim(); + } + } +} diff --git a/Avalonia-PC/Authentication/PcAuthService.cs b/Avalonia-PC/Authentication/PcAuthService.cs new file mode 100644 index 0000000..8ffed2a --- /dev/null +++ b/Avalonia-PC/Authentication/PcAuthService.cs @@ -0,0 +1,50 @@ +using Authentication; +using Avalonia_Services.Core; +using System; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Avalonia_PC.Authentication +{ + public sealed class PcAuthService(PcGlobalTokenService tokenService) : IAuthService + { + public async Task AuthenticateAsync(ServiceEndpointContext context) + { + var token = ExtractBearerToken(context.GetHeader("Authorization")); + if (!await tokenService.ValidateAsync(token)) + { + return null; + } + + var identity = new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, "pc-local"), + new Claim(ClaimTypes.Name, "PC授权用户"), + new Claim(ClaimTypes.Role, "SuperAdmin"), + new Claim(ClaimTypes.Role, "Admin"), + new Claim("auth_type", "pc-global-token"), + ], + "pc-global-token"); + + return new ClaimsPrincipal(identity); + } + + public Task AuthorizeAsync(ClaimsPrincipal user, string policy) + { + return Task.FromResult(user.Identity?.IsAuthenticated == true); + } + + private static string? ExtractBearerToken(string? authorization) + { + if (string.IsNullOrWhiteSpace(authorization)) + { + return null; + } + + const string prefix = "Bearer "; + return authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? authorization[prefix.Length..].Trim() + : authorization.Trim(); + } + } +} diff --git a/Avalonia-PC/Authentication/PcGlobalTokenService.cs b/Avalonia-PC/Authentication/PcGlobalTokenService.cs new file mode 100644 index 0000000..cd06d2a --- /dev/null +++ b/Avalonia-PC/Authentication/PcGlobalTokenService.cs @@ -0,0 +1,148 @@ +using Avalonia_Services.Services.AuthService; +using System; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Authentication +{ + public sealed class PcGlobalTokenService(IPcThirdPartyAuthorizationClient thirdPartyClient) + { + private static readonly string[] SuperRoles = ["SuperAdmin", "Admin"]; + private readonly object _syncRoot = new(); + private PcTokenState? _current; + + private static readonly TimeSpan NormalLifetime = TimeSpan.FromHours(8); + private static readonly TimeSpan TemporaryFailureLifetime = TimeSpan.FromMinutes(20); + private static readonly TimeSpan MaxTemporaryFailureWindow = TimeSpan.FromHours(24); + + public async Task AuthorizeAsync(string? authorizationCode, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(authorizationCode)) + { + return null; + } + + var result = await thirdPartyClient.ValidateAuthorizationCodeAsync(authorizationCode, cancellationToken); + if (result != ThirdPartyAuthCheckResult.Valid) + { + return null; + } + + return IssueToken(authorizationCode, NormalLifetime, resetTemporaryFailureWindow: true); + } + + public async Task RefreshAsync(string? token, CancellationToken cancellationToken = default) + { + PcTokenState? current; + lock (_syncRoot) + { + current = IsCurrentToken(token) ? _current : null; + } + + if (current is null) + { + return null; + } + + var result = await thirdPartyClient.RefreshAuthorizationAsync(current.AuthorizationReference, cancellationToken); + return result switch + { + ThirdPartyAuthCheckResult.Valid => IssueToken(current.AuthorizationReference, NormalLifetime, resetTemporaryFailureWindow: true), + ThirdPartyAuthCheckResult.AuthorizationLost => ClearAndReturnNull(), + ThirdPartyAuthCheckResult.TemporaryFailure => RefreshAfterTemporaryFailure(current), + _ => null, + }; + } + + public async Task ValidateAsync(string? token, CancellationToken cancellationToken = default) + { + PcTokenState? current; + lock (_syncRoot) + { + if (!IsCurrentToken(token)) + { + return false; + } + + current = _current; + if (current is not null && current.ExpiresAt > DateTime.UtcNow) + { + return true; + } + } + + return await RefreshAsync(token, cancellationToken) is not null; + } + + public void Logout(string? token) + { + lock (_syncRoot) + { + if (IsCurrentToken(token)) + { + _current = null; + } + } + } + + private PcTokenResponse IssueToken(string authorizationReference, TimeSpan lifetime, bool resetTemporaryFailureWindow) + { + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + var now = DateTime.UtcNow; + var state = new PcTokenState( + HashToken(token), + authorizationReference, + now.Add(lifetime), + resetTemporaryFailureWindow ? null : _current?.TemporaryFailureStartedAt ?? now); + + lock (_syncRoot) + { + _current = state; + } + + return new PcTokenResponse(token, state.ExpiresAt, SuperRoles); + } + + private PcTokenResponse? RefreshAfterTemporaryFailure(PcTokenState current) + { + var startedAt = current.TemporaryFailureStartedAt ?? DateTime.UtcNow; + if (DateTime.UtcNow - startedAt > MaxTemporaryFailureWindow) + { + return ClearAndReturnNull(); + } + + return IssueToken(current.AuthorizationReference, TemporaryFailureLifetime, resetTemporaryFailureWindow: false); + } + + private PcTokenResponse? ClearAndReturnNull() + { + lock (_syncRoot) + { + _current = null; + } + + return null; + } + + private bool IsCurrentToken(string? token) + { + return !string.IsNullOrWhiteSpace(token) && + _current is not null && + string.Equals(_current.TokenHash, HashToken(token), StringComparison.Ordinal); + } + + private static string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(bytes); + } + + private sealed record PcTokenState( + string TokenHash, + string AuthorizationReference, + DateTime ExpiresAt, + DateTime? TemporaryFailureStartedAt); + } +} diff --git a/Avalonia-PC/Program.cs b/Avalonia-PC/Program.cs index 9b098ab..9744f18 100644 --- a/Avalonia-PC/Program.cs +++ b/Avalonia-PC/Program.cs @@ -1,10 +1,13 @@ -using Avalonia; +using Authentication; +using Avalonia; using Avalonia_Common.Infrastructure; using Avalonia_EFCore.Database; +using Avalonia_PC.Authentication; using Avalonia_PC.Views; using Avalonia_Services.Core; using Avalonia_Services.Endpoints; using Avalonia_Services.Services; +using Avalonia_Services.Services.AuthService; using Microsoft.Extensions.DependencyInjection; using Serilog; using System; @@ -28,10 +31,6 @@ namespace Avalonia_PC // 初始化数据库(自动迁移 + 种子数据) Services.InitializeDatabase(); - // 启动时打印所有拦截的接口 - var endpoints = Services.GetRequiredService(); - EndpointPrinter.PrintEndpoints(endpoints, "Avalonia-PC 拦截接口列表"); - #if DEBUG // 开启 WebView2 远程调试,启动后在 Edge 中访问 edge://inspect 调试网页 Environment.SetEnvironmentVariable( @@ -54,10 +53,15 @@ namespace Avalonia_PC // ---- 业务服务 ---- services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); - // ---- 统一端点 ---- + // ---- 端点注册 ---- var endpointBuilder = new ServiceEndpointBuilder(); AppEndpoints.Configure(endpointBuilder); + AuthEndpoints.ConfigurePc(endpointBuilder); var endpoints = endpointBuilder.Build(); services.AddSingleton(endpoints); diff --git a/Avalonia-Services/Core/EndpointPrinter.cs b/Avalonia-Services/Core/EndpointPrinter.cs index ccb4e0e..e022a74 100644 --- a/Avalonia-Services/Core/EndpointPrinter.cs +++ b/Avalonia-Services/Core/EndpointPrinter.cs @@ -12,15 +12,19 @@ namespace Avalonia_Services.Core /// /// 打印所有已注册端点到控制台。 /// - public static void PrintEndpoints(ServiceEndpointCollection collection, string? title = null) + public static void PrintEndpoints( + ServiceEndpointCollection collection, + string? title = null, + EndpointHostTarget host = EndpointHostTarget.All) { title ??= "API Endpoints"; + var endpoints = collection.ForHost(host).ToList(); - var maxMethodLen = collection.Endpoints.Count > 0 - ? collection.Endpoints.Max(e => e.HttpMethod.Length) + var maxMethodLen = endpoints.Count > 0 + ? endpoints.Max(e => e.HttpMethod.Length) : 4; - var maxPathLen = collection.Endpoints.Count > 0 - ? collection.Endpoints.Max(e => e.Pattern.Length) + var maxPathLen = endpoints.Count > 0 + ? endpoints.Max(e => e.Pattern.Length) : 8; var totalWidth = maxMethodLen + maxPathLen + 5; @@ -31,9 +35,11 @@ namespace Avalonia_Services.Core Console.WriteLine($"║ {"Method".PadRight(maxMethodLen)} │ {"Path".PadRight(maxPathLen)} │ Auth ║"); Console.WriteLine($"╟{separator}╢"); - foreach (var ep in collection.Endpoints.OrderBy(e => e.Pattern)) + foreach (var ep in endpoints.OrderBy(e => e.Pattern)) { - var auth = ep.RequireAuthorization ? (ep.Policy ?? "✓") : "—"; + var auth = ep.RequireAuthorization + ? (ep.Roles.Count > 0 ? string.Join(",", ep.Roles) : ep.Policy ?? "✓") + : "—"; var methodColor = ep.HttpMethod switch { "GET" => ConsoleColor.Green, @@ -57,7 +63,7 @@ namespace Avalonia_Services.Core } Console.WriteLine($"╚{separator}╝"); - Console.WriteLine($" Total: {collection.Endpoints.Count} endpoint(s)"); + Console.WriteLine($" Total: {endpoints.Count} endpoint(s)"); Console.WriteLine(); } } diff --git a/Avalonia-Services/Core/ServiceEndpointCollection.cs b/Avalonia-Services/Core/ServiceEndpointCollection.cs index 1aa4fdb..89f4743 100644 --- a/Avalonia-Services/Core/ServiceEndpointCollection.cs +++ b/Avalonia-Services/Core/ServiceEndpointCollection.cs @@ -1,9 +1,18 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; namespace Avalonia_Services.Core { + [Flags] + public enum EndpointHostTarget + { + Api = 1, + Pc = 2, + All = Api | Pc, + } + /// /// 单个端点定义。 /// @@ -18,6 +27,21 @@ namespace Avalonia_Services.Core /// 端点名称(用于 OpenAPI / 日志) public string? Name { get; set; } + /// OpenAPI 分组标签。 + public string? OpenApiTag { get; set; } + + /// OpenAPI 摘要。 + public string? OpenApiSummary { get; set; } + + /// OpenAPI 描述。 + public string? OpenApiDescription { get; set; } + + /// OpenAPI 请求体类型。 + public Type? OpenApiRequestType { get; set; } + + /// OpenAPI 200 响应数据类型。 + public Type? OpenApiResponseType { get; set; } + /// 端点处理器 public Func> Handler { get; init; } = _ => Task.FromResult(null); @@ -30,6 +54,12 @@ namespace Avalonia_Services.Core /// 鉴权策略名 public string? Policy { get; set; } + /// 允许访问该端点的角色。多个角色满足任意一个即可。 + public List Roles { get; } = new(); + + /// 端点挂载的宿主。默认 API 和 PC 都挂载。 + public EndpointHostTarget HostTarget { get; set; } = EndpointHostTarget.All; + /// /// 设置端点名称(Fluent API)。 /// @@ -38,6 +68,64 @@ namespace Avalonia_Services.Core Name = name; return this; } + + public ServiceEndpoint WithOpenApi( + string tag, + string summary, + string? description = null, + Type? requestType = null, + Type? responseType = null) + { + OpenApiTag = tag; + OpenApiSummary = summary; + OpenApiDescription = description; + OpenApiRequestType = requestType; + OpenApiResponseType = responseType; + return this; + } + + /// + /// 标记端点需要登录。 + /// + public ServiceEndpoint RequireAuth() + { + RequireAuthorization = true; + return this; + } + + /// + /// 标记端点需要指定角色。多个角色满足任意一个即可。 + /// + public ServiceEndpoint RequireRoles(params string[] roles) + { + RequireAuthorization = true; + Roles.Clear(); + Roles.AddRange(roles.Where(role => !string.IsNullOrWhiteSpace(role)).Select(role => role.Trim())); + return this; + } + + /// + /// 只挂载到 Avalonia-API。 + /// + public ServiceEndpoint ApiOnly() + { + HostTarget = EndpointHostTarget.Api; + return this; + } + + /// + /// 只挂载到 Avalonia-PC。 + /// + public ServiceEndpoint PcOnly() + { + HostTarget = EndpointHostTarget.Pc; + return this; + } + + public bool SupportsHost(EndpointHostTarget host) + { + return (HostTarget & host) != 0; + } } /// @@ -48,6 +136,11 @@ namespace Avalonia_Services.Core /// 所有已注册的端点 public List Endpoints { get; } = new(); + public IEnumerable ForHost(EndpointHostTarget host) + { + return Endpoints.Where(endpoint => endpoint.SupportsHost(host)); + } + /// 作用于所有端点的全局过滤器 public List GlobalFilters { get; } = new(); @@ -59,6 +152,14 @@ namespace Avalonia_Services.Core return AddEndpoint(pattern, "GET", handler); } + public ServiceEndpoint MapGet( + string pattern, + Func> handler) + where TService : notnull + { + return MapGet(pattern, CreateServiceHandler(handler)); + } + /// /// 注册一个 POST 端点。 /// @@ -67,6 +168,14 @@ namespace Avalonia_Services.Core return AddEndpoint(pattern, "POST", handler); } + public ServiceEndpoint MapPost( + string pattern, + Func> handler) + where TService : notnull + { + return MapPost(pattern, CreateServiceHandler(handler)); + } + /// /// 注册一个 PUT 端点。 /// @@ -75,6 +184,14 @@ namespace Avalonia_Services.Core return AddEndpoint(pattern, "PUT", handler); } + public ServiceEndpoint MapPut( + string pattern, + Func> handler) + where TService : notnull + { + return MapPut(pattern, CreateServiceHandler(handler)); + } + /// /// 注册一个 DELETE 端点。 /// @@ -83,6 +200,14 @@ namespace Avalonia_Services.Core return AddEndpoint(pattern, "DELETE", handler); } + public ServiceEndpoint MapDelete( + string pattern, + Func> handler) + where TService : notnull + { + return MapDelete(pattern, CreateServiceHandler(handler)); + } + /// /// 添加全局过滤器(作用于所有端点)。 /// @@ -112,6 +237,21 @@ namespace Avalonia_Services.Core Endpoints.Add(endpoint); return endpoint; } + + private static Func> CreateServiceHandler( + Func> handler) + where TService : notnull + { + return async ctx => + { + var serviceProvider = ctx.Items["ServiceProvider"] as IServiceProvider + ?? throw new InvalidOperationException("ServiceProvider 未注入。"); + + await using var scope = serviceProvider.CreateAsyncScope(); + var service = scope.ServiceProvider.GetRequiredService(); + return await handler(service, ctx); + }; + } } /// diff --git a/Avalonia-Services/Endpoints/AppEndpoints.cs b/Avalonia-Services/Endpoints/AppEndpoints.cs index fb45604..c0bb2ef 100644 --- a/Avalonia-Services/Endpoints/AppEndpoints.cs +++ b/Avalonia-Services/Endpoints/AppEndpoints.cs @@ -4,10 +4,6 @@ using Avalonia_EFCore.Models; using Avalonia_Services.Core; using Avalonia_Services.Services; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Linq; -using System.Threading.Tasks; namespace Avalonia_Services.Endpoints { @@ -40,6 +36,7 @@ namespace Avalonia_Services.Endpoints // ---- 业务端点注册 ---- // 天气预报(从数据库读取) endpoints.MapGet("api/wData", GetWeatherForecastsAsync) + .WithOpenApi("Weather", "获取天气预报信息。") .WithName("GetWeatherForecast"); // 获取用户(演示从数据库查询) diff --git a/Avalonia-Services/Endpoints/AuthEndpoints.cs b/Avalonia-Services/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..bed977a --- /dev/null +++ b/Avalonia-Services/Endpoints/AuthEndpoints.cs @@ -0,0 +1,53 @@ +using Avalonia_Services.Core; +using Avalonia_Services.Services.AuthService; + +namespace Avalonia_Services.Endpoints +{ + /// + /// 认证端点统一入口。端点定义在这里,宿主项目只提供对应实现。 + /// + public static class AuthEndpoints + { + public static void ConfigureApi(ServiceEndpointBuilder builder) + { + builder.ConfigureEndpoints(endpoints => + { + endpoints.MapPost("api/auth/login", (service, ctx) => service.LoginAsync(ctx)) + .WithName("ApiLogin") + .WithOpenApi("Auth", "API 登录,返回 access token 和 refresh token。", "", typeof(ApiLoginRequest), typeof(AuthTokenResponse)) + .ApiOnly(); + + endpoints.MapPost("api/auth/refresh", (service, ctx) => service.RefreshAsync(ctx)) + .WithName("ApiRefresh") + .WithOpenApi("Auth", "API refresh token 轮换。", "", typeof(ApiRefreshTokenRequest), typeof(AuthTokenResponse)) + .ApiOnly(); + + endpoints.MapPost("api/auth/logout", (service, ctx) => service.LogoutAsync(ctx)) + .WithName("ApiLogout") + .WithOpenApi("Auth", "API 退出登录并吊销 refresh token。", "", typeof(ApiLogoutRequest)) + .ApiOnly(); + }); + } + + public static void ConfigurePc(ServiceEndpointBuilder builder) + { + builder.ConfigureEndpoints(endpoints => + { + endpoints.MapPost("api/pc/auth/authorize", (service, ctx) => service.AuthorizeAsync(ctx)) + .WithName("PcAuthorize") + .WithOpenApi("Auth", "PC 授权码登录,生成本地全局 token。", "", typeof(PcAuthorizeRequest), typeof(PcTokenResponse)) + .PcOnly(); + + endpoints.MapPost("api/pc/auth/refresh", (service, ctx) => service.RefreshAsync(ctx)) + .WithName("PcRefresh") + .WithOpenApi("Auth", "PC 全局 token 刷新。", "", typeof(PcRefreshRequest), typeof(PcTokenResponse)) + .PcOnly(); + + endpoints.MapPost("api/pc/auth/logout", (service, ctx) => service.LogoutAsync(ctx)) + .WithName("PcLogout") + .WithOpenApi("Auth", "PC 退出登录。", "", typeof(PcLogoutRequest)) + .PcOnly(); + }); + } + } +} diff --git a/Avalonia-Services/Extensions/DesktopEndpointAdapter.cs b/Avalonia-Services/Extensions/DesktopEndpointAdapter.cs index 20f07e4..0aa6ec9 100644 --- a/Avalonia-Services/Extensions/DesktopEndpointAdapter.cs +++ b/Avalonia-Services/Extensions/DesktopEndpointAdapter.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; using Avalonia_Services.Core; namespace Avalonia_Services.Extensions @@ -75,6 +70,7 @@ namespace Avalonia_Services.Extensions { // 查找匹配的端点(忽略大小写 + 方法匹配) var endpoint = _endpoints.Endpoints.FirstOrDefault(e => + e.SupportsHost(EndpointHostTarget.Pc) && string.Equals(e.Pattern, path, StringComparison.OrdinalIgnoreCase) && string.Equals(e.HttpMethod, method, StringComparison.OrdinalIgnoreCase)); @@ -108,7 +104,18 @@ namespace Avalonia_Services.Extensions return RouteResult.Success(ctx.ResponseBody, ctx); } - if (!string.IsNullOrEmpty(endpoint.Policy)) + if (endpoint.Roles.Count > 0) + { + var authorized = await _authService.AuthorizeAsync(user, $"roles:{string.Join(',', endpoint.Roles)}"); + if (!authorized) + { + ctx.StatusCode = 403; + ctx.StatusMessage = "Forbidden"; + ctx.ResponseBody = new { success = false, error = "Forbidden" }; + return RouteResult.Success(ctx.ResponseBody, ctx); + } + } + else if (!string.IsNullOrEmpty(endpoint.Policy)) { var authorized = await _authService.AuthorizeAsync(user, endpoint.Policy); if (!authorized) diff --git a/Avalonia-Services/Services/AuthService/AuthContracts.cs b/Avalonia-Services/Services/AuthService/AuthContracts.cs new file mode 100644 index 0000000..906bb56 --- /dev/null +++ b/Avalonia-Services/Services/AuthService/AuthContracts.cs @@ -0,0 +1,37 @@ +namespace Avalonia_Services.Services.AuthService +{ + public sealed record ApiLoginRequest(string? Account, string? Password, string[]? Roles = null); + + public sealed record ApiRefreshTokenRequest(string? RefreshToken); + + public sealed record ApiLogoutRequest(string? RefreshToken); + + public sealed record AuthTokenResponse( + string AccessToken, + string RefreshToken, + DateTime AccessTokenExpiresAt, + DateTime RefreshTokenExpiresAt, + string[] Roles); + + public sealed record PcAuthorizeRequest(string? AuthorizationCode); + + public sealed record PcRefreshRequest(string? Token); + + public sealed record PcLogoutRequest(string? Token); + + public sealed record PcTokenResponse(string Token, DateTime ExpiresAt, string[] Roles); + + public enum ThirdPartyAuthCheckResult + { + Valid, + AuthorizationLost, + TemporaryFailure, + } + + public interface IPcThirdPartyAuthorizationClient + { + Task ValidateAuthorizationCodeAsync(string authorizationCode, CancellationToken cancellationToken = default); + + Task RefreshAuthorizationAsync(string authorizationReference, CancellationToken cancellationToken = default); + } +} diff --git a/Avalonia-Services/Services/AuthService/AuthEndpointServices.cs b/Avalonia-Services/Services/AuthService/AuthEndpointServices.cs new file mode 100644 index 0000000..37bfa0b --- /dev/null +++ b/Avalonia-Services/Services/AuthService/AuthEndpointServices.cs @@ -0,0 +1,23 @@ +using Avalonia_Services.Core; +using System.Threading.Tasks; + +namespace Avalonia_Services.Services.AuthService +{ + public interface IApiAuthEndpointService + { + Task LoginAsync(ServiceEndpointContext ctx); + + Task RefreshAsync(ServiceEndpointContext ctx); + + Task LogoutAsync(ServiceEndpointContext ctx); + } + + public interface IPcAuthEndpointService + { + Task AuthorizeAsync(ServiceEndpointContext ctx); + + Task RefreshAsync(ServiceEndpointContext ctx); + + Task LogoutAsync(ServiceEndpointContext ctx); + } +}