forked from xiongyuxing/tiku-backend.net
feat: add auth dto and scalar templates
This commit is contained in:
89
Tiku.Api/Contracts/AuthDtos.cs
Normal file
89
Tiku.Api/Contracts/AuthDtos.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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/unionid,upsert 用户身份并创建应用会话。")]
|
||||
[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_key,upsert 用户身份并创建应用会话。")]
|
||||
[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 对应的数据库 session;session 校验开启时,旧 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);
|
||||
|
||||
33
Tiku.Api/OpenApi/BearerSecuritySchemeTransformer.cs
Normal file
33
Tiku.Api/OpenApi/BearerSecuritySchemeTransformer.cs
Normal 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 会自动补。"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user