350 lines
14 KiB
C#
350 lines
14 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.AspNetCore.RateLimiting;
|
||
using Microsoft.Extensions.Options;
|
||
using Tiku.Application.Auth;
|
||
using Tiku.Api.Contracts;
|
||
using Tiku.Api.Options;
|
||
using Tiku.Application.Security;
|
||
using Tiku.Application.Tenancy;
|
||
using Tiku.Infrastructure.Content;
|
||
using Tiku.Domain.Tenancy;
|
||
|
||
namespace Tiku.Api.Controllers;
|
||
|
||
[ApiController]
|
||
[Route("api/auth")]
|
||
[Produces("application/json")]
|
||
public sealed class AuthController(
|
||
IAuthService authService,
|
||
ISmsVerificationService smsVerificationService,
|
||
IAuthSessionStore sessionStore,
|
||
ITenantContext tenantContext,
|
||
ITenantContextInitializer tenantContextInitializer,
|
||
ITenantDirectory tenantDirectory,
|
||
ICurrentUser currentUser,
|
||
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
|
||
{
|
||
[AllowAnonymous]
|
||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||
[HttpPost("sms/send")]
|
||
[EndpointSummary("发送短信验证码")]
|
||
[EndpointDescription("发送登录用途短信验证码,并应用租户级短信限流。")]
|
||
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
|
||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status429TooManyRequests)]
|
||
public async Task<ActionResult<SmsSendResult>> SendSmsCode(
|
||
[FromBody] SendSmsCodeDto request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var realm = request.Realm!.Value;
|
||
if (realm != AuthRealm.Tenant)
|
||
{
|
||
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
|
||
}
|
||
|
||
var tenantId = await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken)
|
||
?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
|
||
var result = await smsVerificationService.CreateCodeAsync(
|
||
new SendSmsCodeRequest(
|
||
tenantId,
|
||
request.Phone,
|
||
SmsPurpose.Login,
|
||
GetIpAddress(),
|
||
Request.Headers.UserAgent.ToString(),
|
||
request.DeviceId),
|
||
cancellationToken);
|
||
return Accepted(result);
|
||
}
|
||
|
||
[AllowAnonymous]
|
||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||
[HttpPost("login/password")]
|
||
[EndpointSummary("手机号密码登录")]
|
||
[EndpointDescription("使用本地手机号和密码登录,签发 JWT access token 与数据库 refresh/session。")]
|
||
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||
public async Task<ActionResult<AuthenticationResultDto>> LoginWithPassword(
|
||
[FromBody] PasswordLoginDto request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var realm = request.Realm!.Value;
|
||
var identifier = request.Identifier ?? request.Phone;
|
||
if (string.IsNullOrWhiteSpace(identifier))
|
||
{
|
||
throw new RequiredFieldException("identifier is required.");
|
||
}
|
||
var result = await authService.LoginWithPasswordAsync(
|
||
new PasswordLoginRequest(
|
||
realm,
|
||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||
identifier,
|
||
request.Password,
|
||
GetIpAddress(),
|
||
Request.Headers.UserAgent.ToString()),
|
||
cancellationToken);
|
||
|
||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||
}
|
||
|
||
[AllowAnonymous]
|
||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||
[HttpPost("login/sms")]
|
||
[EndpointSummary("短信验证码登录")]
|
||
[EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")]
|
||
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||
public async Task<ActionResult<AuthenticationResultDto>> LoginWithSms(
|
||
[FromBody] SmsLoginDto request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var realm = request.Realm!.Value;
|
||
var result = await authService.LoginWithSmsAsync(
|
||
new SmsLoginRequest(
|
||
realm,
|
||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||
request.Phone,
|
||
request.Code,
|
||
GetIpAddress(),
|
||
Request.Headers.UserAgent.ToString()),
|
||
cancellationToken);
|
||
|
||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||
}
|
||
|
||
[AllowAnonymous]
|
||
[HttpPost("oauth/wechat")]
|
||
[EndpointSummary("微信网页 OAuth 登录")]
|
||
[EndpointDescription("使用微信网页授权 code 换取 openid/unionid,upsert 用户身份并创建应用会话。")]
|
||
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
|
||
public async Task<ActionResult<AuthenticationResultDto>> LoginWithWechatWeb(
|
||
[FromBody] OAuthCodeDto request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var realm = request.Realm!.Value;
|
||
var result = await authService.LoginWithWechatWebAsync(
|
||
new WechatLoginRequest(
|
||
realm,
|
||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||
request.Code,
|
||
GetIpAddress(),
|
||
Request.Headers.UserAgent.ToString()),
|
||
cancellationToken);
|
||
|
||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||
}
|
||
|
||
[AllowAnonymous]
|
||
[HttpPost("oauth/wechat-miniapp")]
|
||
[EndpointSummary("微信小程序登录")]
|
||
[EndpointDescription("使用小程序 wx.login 返回的 code 换取 openid/session_key,upsert 用户身份并创建应用会话。")]
|
||
[ProducesResponseType<AuthenticationResultDto>(StatusCodes.Status200OK)]
|
||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
|
||
public async Task<ActionResult<AuthenticationResultDto>> LoginWithWechatMiniApp(
|
||
[FromBody] OAuthCodeDto request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var realm = request.Realm!.Value;
|
||
var result = await authService.LoginWithWechatMiniAppAsync(
|
||
new WechatLoginRequest(
|
||
realm,
|
||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||
request.Code,
|
||
GetIpAddress(),
|
||
Request.Headers.UserAgent.ToString()),
|
||
cancellationToken);
|
||
|
||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||
}
|
||
|
||
[AllowAnonymous]
|
||
[HttpPost("refresh")]
|
||
[EndpointSummary("刷新登录会话")]
|
||
[EndpointDescription("使用 refresh token 轮换数据库 session,并签发新的 access/refresh token。")]
|
||
public async Task<ActionResult<AuthTokenPair>> Refresh(
|
||
[FromBody] RefreshSessionDto request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
ResolveRefreshTokenTenant(request.RefreshToken);
|
||
var result = await authService.RefreshAsync(
|
||
new RefreshSessionRequest(
|
||
request.RefreshToken,
|
||
GetIpAddress(),
|
||
Request.Headers.UserAgent.ToString()),
|
||
cancellationToken);
|
||
|
||
return Ok(result);
|
||
}
|
||
|
||
[AllowAnonymous]
|
||
[HttpPost("logout")]
|
||
[EndpointSummary("退出登录")]
|
||
[EndpointDescription("撤销 refresh token 对应的数据库 session;session 校验开启时,旧 access token 也会被拒绝。")]
|
||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||
public async Task<IActionResult> Logout(
|
||
[FromBody] RefreshSessionDto request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
ResolveRefreshTokenTenant(request.RefreshToken);
|
||
await authService.LogoutAsync(
|
||
new LogoutSessionRequest(request.RefreshToken),
|
||
cancellationToken);
|
||
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("logout-all")]
|
||
[Authorize]
|
||
[EndpointSummary("退出全部登录会话")]
|
||
[EndpointDescription("撤销当前用户全部 refresh/session,会话校验开启时旧 access token 也会被拒绝。")]
|
||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
|
||
{
|
||
if (currentUser.UserId is not { } userId)
|
||
{
|
||
return Unauthorized();
|
||
}
|
||
|
||
await authService.LogoutAllAsync(userId, cancellationToken);
|
||
return NoContent();
|
||
}
|
||
|
||
[AllowAnonymous]
|
||
[HttpPost("password/change-required")]
|
||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||
[EndpointSummary("修改首次登录必改密码")]
|
||
[EndpointDescription("校验密码变更挑战令牌并设置新密码,成功后签发新的登录会话。")]
|
||
public async Task<ActionResult<AuthenticationResultDto>> ChangeRequiredPassword(
|
||
[FromBody] RequiredPasswordChangeDto request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
ResolveAuthChallengeTenant(request.ChallengeToken);
|
||
var result = await authService.ChangeRequiredPasswordAsync(
|
||
new PasswordChangeChallengeRequest(
|
||
request.ChallengeToken, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()),
|
||
cancellationToken);
|
||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||
}
|
||
|
||
private void ResolveRefreshTokenTenant(string refreshToken)
|
||
{
|
||
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator))
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (locator.Realm == AuthRealm.Platform)
|
||
{
|
||
EnsurePlatformHost();
|
||
if (tenantContext.IsResolved)
|
||
{
|
||
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!tenantContext.IsResolved)
|
||
{
|
||
throw new RequiredFieldException(
|
||
"tenant refresh/logout requires a tenant host or x-tenant-code matching the refresh token.");
|
||
}
|
||
|
||
tenantContextInitializer.Initialize(locator.TenantId!.Value, null, TenantResolutionSource.RefreshToken);
|
||
}
|
||
|
||
private void ResolveAuthChallengeTenant(string challengeToken)
|
||
{
|
||
var parts = challengeToken.Split('.', 4, StringSplitOptions.None);
|
||
if (parts.Length != 4 || parts[0] != "c1")
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (parts[1] == "p" && parts[2] == "-")
|
||
{
|
||
EnsurePlatformHost();
|
||
if (tenantContext.IsResolved)
|
||
{
|
||
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (parts[1] != "t" || !Guid.TryParseExact(parts[2], "N", out var tenantId) || !tenantContext.IsResolved)
|
||
{
|
||
throw new RequiredFieldException(
|
||
"tenant authentication challenge requires a tenant host or x-tenant-code.");
|
||
}
|
||
|
||
tenantContextInitializer.Initialize(tenantId, null, TenantResolutionSource.RefreshToken);
|
||
}
|
||
|
||
private string? GetIpAddress()
|
||
{
|
||
return HttpContext.Connection.RemoteIpAddress?.ToString();
|
||
}
|
||
|
||
private async Task<Guid?> ResolveRealmTenantIdAsync(
|
||
AuthRealm realm,
|
||
string? tenantCode,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (realm == AuthRealm.Platform)
|
||
{
|
||
EnsurePlatformHost();
|
||
if (tenantContext.IsResolved || !string.IsNullOrWhiteSpace(tenantCode))
|
||
{
|
||
throw new RequiredFieldException("platform realm does not accept tenantCode and must use a platform host.");
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
if (tenantContext.TenantId.HasValue)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(tenantCode) &&
|
||
!string.Equals(tenantContext.TenantCode, tenantCode.Trim(), StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var supplied = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken);
|
||
if (supplied?.TenantId != tenantContext.TenantId.Value)
|
||
{
|
||
throw new TenantContextConflictException(
|
||
tenantContext.TenantId.Value,
|
||
supplied?.TenantId ?? Guid.Empty);
|
||
}
|
||
}
|
||
|
||
return tenantContext.TenantId.Value;
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(tenantCode))
|
||
{
|
||
throw new RequiredFieldException("tenantCode is required when the request host does not resolve a tenant.");
|
||
}
|
||
|
||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
|
||
?? throw new TenantNotFoundException();
|
||
tenantContextInitializer.Initialize(
|
||
tenant.TenantId,
|
||
tenant.TenantCode,
|
||
TenantResolutionSource.TenantCode);
|
||
return tenant.TenantId;
|
||
}
|
||
|
||
private void EnsurePlatformHost()
|
||
{
|
||
var requestHost = Request.Host.Host.Trim().TrimEnd('.');
|
||
if (!tenantResolutionOptions.Value.PlatformHosts.Any(host =>
|
||
string.Equals(
|
||
host.Trim().TrimEnd('.'),
|
||
requestHost,
|
||
StringComparison.OrdinalIgnoreCase)))
|
||
{
|
||
throw new RequiredFieldException("platform realm is only available on a configured platform host.");
|
||
}
|
||
}
|
||
}
|