feat: add auth dto and scalar templates

This commit is contained in:
xiong
2026-07-26 13:18:18 +08:00
parent 70e2503d18
commit 00413d6b3b
5 changed files with 196 additions and 44 deletions

View File

@@ -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<string, object?>? 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
};
}
}

View File

@@ -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<ActionResult<AuthenticatedUser>> LoginWithPassword(
PasswordLoginHttpRequest request,
[EndpointSummary("手机号密码登录")]
[EndpointDescription("使用本地手机号和密码登录,签发 JWT access token 与数据库 refresh/session。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<AuthenticatedUserDto>> 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<ActionResult<AuthenticatedUser>> LoginWithSms(
SmsLoginHttpRequest request,
[EndpointSummary("短信验证码登录")]
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<AuthenticatedUserDto>> 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<ActionResult<AuthenticatedUser>> LoginWithWechatWeb(
WechatLoginHttpRequest request,
[EndpointSummary("微信网页 OAuth 登录")]
[EndpointDescription("使用微信网页授权 code 换取 openid/unionidupsert 用户身份并创建应用会话。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<AuthenticatedUserDto>> 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<ActionResult<AuthenticatedUser>> LoginWithWechatMiniApp(
WechatLoginHttpRequest request,
[EndpointSummary("微信小程序登录")]
[EndpointDescription("使用小程序 wx.login 返回的 code 换取 openid/session_keyupsert 用户身份并创建应用会话。")]
[ProducesResponseType<AuthenticatedUserDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<AuthenticatedUserDto>> 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<ActionResult<AuthTokenPair>> 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 对应的数据库 sessionsession 校验开启时,旧 access token 也会被拒绝。")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> 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);

View File

@@ -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<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes["BearerAuth"] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "输入 JWT access token。不要带 Bearer 前缀Scalar 会自动补。"
};
}
}

View File

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

View File

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