From 00413d6b3bacc07613b590b7718473df9be8017b Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 13:18:18 +0800 Subject: [PATCH] feat: add auth dto and scalar templates --- Tiku.Api/Contracts/AuthDtos.cs | 89 +++++++++++++++++++ Tiku.Api/Controllers/AuthController.cs | 69 +++++++------- .../BearerSecuritySchemeTransformer.cs | 33 +++++++ Tiku.Api/Program.cs | 16 ++-- .../Api/AuthEndpointTests.cs | 33 +++++-- 5 files changed, 196 insertions(+), 44 deletions(-) create mode 100644 Tiku.Api/Contracts/AuthDtos.cs create mode 100644 Tiku.Api/OpenApi/BearerSecuritySchemeTransformer.cs diff --git a/Tiku.Api/Contracts/AuthDtos.cs b/Tiku.Api/Contracts/AuthDtos.cs new file mode 100644 index 0000000..ec52e07 --- /dev/null +++ b/Tiku.Api/Contracts/AuthDtos.cs @@ -0,0 +1,89 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Tiku.Application.Auth; + +namespace Tiku.Api.Contracts; + +public sealed class PasswordLoginDto +{ + [Required] + [Description("租户 ID。登录阶段允许客户端指定租户,登录后的业务接口以 JWT/session 解析出的当前租户为准。")] + public Guid TenantId { get; set; } + + [Required] + [StringLength(32)] + [Description("手机号,建议前端提交规范化后的中国大陆手机号。")] + public string Phone { get; set; } = string.Empty; + + [Required] + [StringLength(128, MinimumLength = 6)] + [Description("用户密码。")] + public string Password { get; set; } = string.Empty; +} + +public sealed class SmsLoginDto +{ + [Required] + [Description("租户 ID。")] + public Guid TenantId { get; set; } + + [Required] + [StringLength(32)] + [Description("中国大陆手机号。")] + public string Phone { get; set; } = string.Empty; + + [Required] + [StringLength(12, MinimumLength = 4)] + [Description("收到的短信验证码。")] + public string Code { get; set; } = string.Empty; +} + +public sealed class OAuthCodeDto +{ + [Required] + [Description("租户 ID。")] + public Guid TenantId { get; set; } + + [Required] + [StringLength(512)] + [Description("OAuth 平台返回的一次性授权 code。")] + public string Code { get; set; } = string.Empty; + + [Description("客户端可提供的公开用户资料,不允许包含 token/secret。当前后端先保留模板字段,后续按业务需要逐步使用。")] + public Dictionary? Profile { get; set; } + + [StringLength(20)] + [Description("微信用户资料语言,例如 zh_CN。当前微信小程序登录不会使用该字段。")] + public string? Lang { get; set; } +} + +public sealed class RefreshSessionDto +{ + [Required] + [StringLength(2048)] + [Description("refresh token。服务端只存储哈希,明文只返回给客户端一次。")] + public string RefreshToken { get; set; } = string.Empty; +} + +public sealed class AuthenticatedUserDto +{ + public Guid UserId { get; init; } + public string? Phone { get; init; } + public string? Email { get; init; } + public string? Name { get; init; } + public TenantMembershipSummary Tenant { get; init; } = default!; + public AuthTokenPair Tokens { get; init; } = default!; + + public static AuthenticatedUserDto FromApplication(AuthenticatedUser user) + { + return new AuthenticatedUserDto + { + UserId = user.UserId, + Phone = user.Phone, + Email = user.Email, + Name = user.Name, + Tenant = user.Tenant, + Tokens = user.Tokens + }; + } +} diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs index 16496ee..ea15182 100644 --- a/Tiku.Api/Controllers/AuthController.cs +++ b/Tiku.Api/Controllers/AuthController.cs @@ -1,17 +1,23 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Application.Auth; +using Tiku.Api.Contracts; namespace Tiku.Api.Controllers; [ApiController] [Route("api/auth")] +[Produces("application/json")] public sealed class AuthController(IAuthService authService) : ControllerBase { [AllowAnonymous] [HttpPost("login/password")] - public async Task> LoginWithPassword( - PasswordLoginHttpRequest request, + [EndpointSummary("手机号密码登录")] + [EndpointDescription("使用本地手机号和密码登录,签发 JWT access token 与数据库 refresh/session。")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> LoginWithPassword( + [FromBody] PasswordLoginDto request, CancellationToken cancellationToken) { var result = await authService.LoginWithPasswordAsync( @@ -23,13 +29,17 @@ public sealed class AuthController(IAuthService authService) : ControllerBase Request.Headers.UserAgent.ToString()), cancellationToken); - return Ok(result); + return Ok(AuthenticatedUserDto.FromApplication(result)); } [AllowAnonymous] [HttpPost("login/sms")] - public async Task> LoginWithSms( - SmsLoginHttpRequest request, + [EndpointSummary("短信验证码登录")] + [EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> LoginWithSms( + [FromBody] SmsLoginDto request, CancellationToken cancellationToken) { var result = await authService.LoginWithSmsAsync( @@ -41,13 +51,18 @@ public sealed class AuthController(IAuthService authService) : ControllerBase Request.Headers.UserAgent.ToString()), cancellationToken); - return Ok(result); + return Ok(AuthenticatedUserDto.FromApplication(result)); } [AllowAnonymous] [HttpPost("oauth/wechat")] - public async Task> LoginWithWechatWeb( - WechatLoginHttpRequest request, + [EndpointSummary("微信网页 OAuth 登录")] + [EndpointDescription("使用微信网页授权 code 换取 openid/unionid,upsert 用户身份并创建应用会话。")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task> LoginWithWechatWeb( + [FromBody] OAuthCodeDto request, CancellationToken cancellationToken) { var result = await authService.LoginWithWechatWebAsync( @@ -58,13 +73,18 @@ public sealed class AuthController(IAuthService authService) : ControllerBase Request.Headers.UserAgent.ToString()), cancellationToken); - return Ok(result); + return Ok(AuthenticatedUserDto.FromApplication(result)); } [AllowAnonymous] [HttpPost("oauth/wechat-miniapp")] - public async Task> LoginWithWechatMiniApp( - WechatLoginHttpRequest request, + [EndpointSummary("微信小程序登录")] + [EndpointDescription("使用小程序 wx.login 返回的 code 换取 openid/session_key,upsert 用户身份并创建应用会话。")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task> LoginWithWechatMiniApp( + [FromBody] OAuthCodeDto request, CancellationToken cancellationToken) { var result = await authService.LoginWithWechatMiniAppAsync( @@ -75,13 +95,15 @@ public sealed class AuthController(IAuthService authService) : ControllerBase Request.Headers.UserAgent.ToString()), cancellationToken); - return Ok(result); + return Ok(AuthenticatedUserDto.FromApplication(result)); } [AllowAnonymous] [HttpPost("refresh")] + [EndpointSummary("刷新登录会话")] + [EndpointDescription("使用 refresh token 轮换数据库 session,并签发新的 access/refresh token。")] public async Task> Refresh( - RefreshHttpRequest request, + [FromBody] RefreshSessionDto request, CancellationToken cancellationToken) { var result = await authService.RefreshAsync( @@ -96,8 +118,11 @@ public sealed class AuthController(IAuthService authService) : ControllerBase [AllowAnonymous] [HttpPost("logout")] + [EndpointSummary("退出登录")] + [EndpointDescription("撤销 refresh token 对应的数据库 session;session 校验开启时,旧 access token 也会被拒绝。")] + [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task Logout( - RefreshHttpRequest request, + [FromBody] RefreshSessionDto request, CancellationToken cancellationToken) { await authService.LogoutAsync( @@ -112,19 +137,3 @@ public sealed class AuthController(IAuthService authService) : ControllerBase return HttpContext.Connection.RemoteIpAddress?.ToString(); } } - -public sealed record PasswordLoginHttpRequest( - Guid TenantId, - string Phone, - string Password); - -public sealed record SmsLoginHttpRequest( - Guid TenantId, - string Phone, - string Code); - -public sealed record WechatLoginHttpRequest( - Guid TenantId, - string Code); - -public sealed record RefreshHttpRequest(string RefreshToken); diff --git a/Tiku.Api/OpenApi/BearerSecuritySchemeTransformer.cs b/Tiku.Api/OpenApi/BearerSecuritySchemeTransformer.cs new file mode 100644 index 0000000..3da201b --- /dev/null +++ b/Tiku.Api/OpenApi/BearerSecuritySchemeTransformer.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Tiku.Api.OpenApi; + +internal sealed class BearerSecuritySchemeTransformer( + IAuthenticationSchemeProvider authenticationSchemeProvider) : IOpenApiDocumentTransformer +{ + public async Task TransformAsync( + OpenApiDocument document, + OpenApiDocumentTransformerContext context, + CancellationToken cancellationToken) + { + var authenticationSchemes = await authenticationSchemeProvider.GetAllSchemesAsync(); + if (authenticationSchemes.All(scheme => scheme.Name != JwtBearerDefaults.AuthenticationScheme)) + { + return; + } + + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes["BearerAuth"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "输入 JWT access token。不要带 Bearer 前缀,Scalar 会自动补。" + }; + } +} diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs index fbf39e3..3f824d5 100644 --- a/Tiku.Api/Program.cs +++ b/Tiku.Api/Program.cs @@ -5,6 +5,7 @@ using Scalar.AspNetCore; using System.Text; using System.Text.Json.Serialization; using Tiku.Api.Middleware; +using Tiku.Api.OpenApi; using Tiku.Api.Security; using Tiku.Application; using Tiku.Application.Security; @@ -18,7 +19,10 @@ builder.Services.AddControllers() { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); -builder.Services.AddOpenApi(); +builder.Services.AddOpenApi(options => +{ + options.AddDocumentTransformer(); +}); builder.Services.AddProblemDetails(); builder.Services.AddApplication(); @@ -103,7 +107,10 @@ var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); - app.MapScalarApiReference(); + app.MapScalarApiReference(options => options + .WithTitle("TIKU Backend API") + .AddPreferredSecuritySchemes("BearerAuth") + .EnablePersistentAuthentication()); } app.UseMiddleware(); @@ -117,8 +124,3 @@ app.MapControllers(); app.Run(); public partial class Program; - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} diff --git a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs index 05a1b5a..de5620f 100644 --- a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs @@ -3,7 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Auth; -using Tiku.Api.Controllers; +using Tiku.Api.Contracts; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; @@ -22,7 +22,12 @@ public sealed class AuthEndpointTests var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/password", - new PasswordLoginHttpRequest(seed.TenantId, seed.Phone, "passw0rd!")); + new PasswordLoginDto + { + TenantId = seed.TenantId, + Phone = seed.Phone, + Password = "passw0rd!" + }); var loginJson = await ReadJsonAsync(loginResponse); var accessToken = loginJson.RootElement .GetProperty("tokens") @@ -50,7 +55,12 @@ public sealed class AuthEndpointTests var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/sms", - new SmsLoginHttpRequest(seed.TenantId, seed.Phone, "123456")); + new SmsLoginDto + { + TenantId = seed.TenantId, + Phone = seed.Phone, + Code = "123456" + }); var loginJson = await ReadJsonAsync(loginResponse); var accessToken = loginJson.RootElement .GetProperty("tokens") @@ -72,7 +82,12 @@ public sealed class AuthEndpointTests using var client = factory.CreateClient(); var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/password", - new PasswordLoginHttpRequest(seed.TenantId, seed.Phone, "passw0rd!")); + new PasswordLoginDto + { + TenantId = seed.TenantId, + Phone = seed.Phone, + Password = "passw0rd!" + }); var loginJson = await ReadJsonAsync(loginResponse); var tokens = loginJson.RootElement.GetProperty("tokens"); var accessToken = tokens.GetProperty("accessToken").GetString(); @@ -80,12 +95,12 @@ public sealed class AuthEndpointTests var logoutResponse = await client.PostAsJsonAsync( "/api/auth/logout", - new RefreshHttpRequest(refreshToken!)); + new RefreshSessionDto { RefreshToken = refreshToken! }); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); var meResponse = await client.GetAsync("/api/me"); var refreshResponse = await client.PostAsJsonAsync( "/api/auth/refresh", - new RefreshHttpRequest(refreshToken!)); + new RefreshSessionDto { RefreshToken = refreshToken! }); Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode); @@ -119,7 +134,11 @@ public sealed class AuthEndpointTests var loginResponse = await client.PostAsJsonAsync( "/api/auth/oauth/wechat-miniapp", - new WechatLoginHttpRequest(tenantId, "wx-code")); + new OAuthCodeDto + { + TenantId = tenantId, + Code = "wx-code" + }); var loginJson = await ReadJsonAsync(loginResponse); var accessToken = loginJson.RootElement .GetProperty("tokens")