Files
tiku-backend.net/Tiku.Api/Controllers/BrowserAuthController.cs
xiong 558b2a4ea8
Some checks failed
ci / release-gate (push) Has been cancelled
fix:修复openapi文档错误
2026-08-03 14:34:23 +08:00

361 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Security.Cryptography;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Tiku.Api.Contracts;
using Tiku.Api.Options;
using Tiku.Application.Auth;
using Tiku.Application.Content;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
namespace Tiku.Api.Controllers;
[ApiController]
[Tags("租户端-浏览器认证")]
[Route("api/tenant/auth/browser")]
[Produces("application/json")]
public sealed class BrowserAuthController(
IAuthService authService,
IOwnerActivationService ownerActivationService,
ISmsVerificationService smsVerificationService,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
ITenantDirectory tenantDirectory,
ICurrentUser currentUser,
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
{
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
[HttpPost("activation/complete")]
[EndpointSummary("完成租户 Owner 激活并建立浏览器会话")]
public async Task<ActionResult<object>> CompleteOwnerActivation(
CompleteOwnerActivationDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var tenantId = tenantContext.TenantId ?? throw new TenantNotFoundException();
var result = await ownerActivationService.CompleteAndAuthenticateAsync(
request.ToRequest(),
tenantId,
Request.Host.Host,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
cancellationToken);
return Ok(WriteResult(result));
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("sms/send")]
[EndpointSummary("发送浏览器短信验证码")]
public async Task<ActionResult<SmsSendResult>> SendSmsCode(
[FromBody] SendSmsCodeDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
EnsureTenantRealm(realm);
if (realm != AuthRealm.Tenant)
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
var tenantId = await ResolveTenantIdAsync(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,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
request.DeviceId), cancellationToken);
return Accepted(result);
}
[AllowAnonymous]
[HttpPost("login/password")]
[EndpointSummary("浏览器手机号密码登录")]
public async Task<ActionResult<object>> LoginWithPassword(
[FromBody] PasswordLoginDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
EnsureTenantRealm(realm);
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 ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
identifier,
request.Password,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()), cancellationToken);
return Ok(WriteResult(result));
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("login/sms")]
[EndpointSummary("浏览器短信验证码登录")]
public async Task<ActionResult<object>> LoginWithSms(
[FromBody] SmsLoginDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
EnsureTenantRealm(realm);
var result = await authService.LoginWithSmsAsync(new SmsLoginRequest(
realm,
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Phone,
request.Code,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()), cancellationToken);
return Ok(WriteResult(result));
}
[AllowAnonymous]
[HttpPost("oauth/wechat")]
[EndpointSummary("浏览器微信网页 OAuth 登录")]
public async Task<ActionResult<object>> LoginWithWechatWeb(
[FromBody] OAuthCodeDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
EnsureTenantRealm(realm);
var result = await authService.LoginWithWechatWebAsync(new WechatLoginRequest(
realm,
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Code,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()), cancellationToken);
return Ok(WriteResult(result));
}
[AllowAnonymous]
[HttpPost("oauth/wechat-miniapp")]
[EndpointSummary("浏览器微信小程序登录")]
public async Task<ActionResult<object>> LoginWithWechatMiniApp(
[FromBody] OAuthCodeDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var realm = request.Realm!.Value;
EnsureTenantRealm(realm);
var result = await authService.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
realm,
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
request.Code,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()), cancellationToken);
return Ok(WriteResult(result));
}
[AllowAnonymous]
[HttpPost("refresh")]
[EndpointSummary("刷新浏览器登录会话")]
[EndpointDescription("读取浏览器 refresh cookie轮换会话并重新写入认证 cookie。")]
public async Task<ActionResult<object>> Refresh(CancellationToken cancellationToken)
{
var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie];
if (string.IsNullOrWhiteSpace(refreshToken)) return Unauthorized();
var tokens = await authService.RefreshAsync(new RefreshSessionRequest(
refreshToken,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()), cancellationToken);
WriteCookies(tokens);
return Ok(new { status = "authenticated" });
}
[AllowAnonymous]
[HttpPost("logout")]
[EndpointSummary("退出浏览器登录")]
[EndpointDescription("撤销浏览器 refresh cookie 对应会话,并清理 access、refresh 与 CSRF cookie。")]
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
{
var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie];
if (!string.IsNullOrWhiteSpace(refreshToken))
await authService.LogoutAsync(new LogoutSessionRequest(refreshToken), cancellationToken);
ClearCookies();
return NoContent();
}
[Authorize]
[HttpPost("logout-all")]
[EndpointSummary("退出全部浏览器登录会话")]
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId) return Unauthorized();
await authService.LogoutAllAsync(userId, cancellationToken);
ClearCookies();
return NoContent();
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("password/reset/sms/send")]
[EndpointSummary("发送浏览器密码重置短信验证码")]
public async Task<ActionResult<SmsSendResult>> SendPasswordResetCode(
PasswordResetSmsSendDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
?? throw new RequiredFieldException("tenantCode is required for password reset.");
var result = await authService.RequestPasswordResetAsync(
new PasswordResetCodeRequest(
tenantId,
request.Phone,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString(),
request.DeviceId),
cancellationToken);
return Accepted(result);
}
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
[HttpPost("password/reset")]
[EndpointSummary("使用短信验证码重置浏览器账号密码")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ResetPassword(
PasswordResetDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
?? throw new RequiredFieldException("tenantCode is required for password reset.");
await authService.ResetPasswordAsync(
new PasswordResetRequest(
tenantId,
request.Phone,
request.Code,
request.NewPassword,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
ClearCookies();
return NoContent();
}
[Authorize]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
[HttpPost("password/change")]
[EndpointSummary("浏览器已登录用户修改密码")]
public async Task<ActionResult<object>> ChangePassword(
AuthenticatedPasswordChangeDto request,
CancellationToken cancellationToken)
{
EnsureTrustedOrigin();
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized();
var result = await authService.ChangePasswordAsync(
new AuthenticatedPasswordChangeRequest(
userId,
sessionId,
request.CurrentPassword,
request.NewPassword,
HttpContext.Connection.RemoteIpAddress?.ToString(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(WriteResult(result));
}
private object WriteResult(AuthenticationResult result)
{
if (result.User?.Tokens is { } tokens) WriteCookies(tokens);
return new
{
status = result.Status.ToString(),
user = result.User is null
? null
: new
{
result.User.UserId,
result.User.Phone,
result.User.Email,
result.User.Name,
result.User.Realm,
result.User.Tenant
},
result.ChallengeToken,
result.ChallengeExpiresAt
};
}
private void WriteCookies(AuthTokenPair tokens)
{
Response.Cookies.Append(BrowserAuthOptions.AccessCookie, tokens.AccessToken, new CookieOptions
{
Secure = true,
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
MaxAge = TimeSpan.FromMinutes(15)
});
Response.Cookies.Append(BrowserAuthOptions.RefreshCookie, tokens.RefreshToken, new CookieOptions
{
Secure = true,
HttpOnly = true,
SameSite = SameSiteMode.Strict,
Path = "/api/tenant/auth/browser",
MaxAge = TimeSpan.FromDays(30)
});
Response.Cookies.Append(BrowserAuthOptions.CsrfCookie,
Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(), new CookieOptions
{
Secure = true,
HttpOnly = false,
SameSite = SameSiteMode.Strict,
Path = "/"
});
}
private void ClearCookies()
{
Response.Cookies.Delete(BrowserAuthOptions.AccessCookie, new CookieOptions { Secure = true, Path = "/" });
Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie,
new CookieOptions { Secure = true, Path = "/api/tenant/auth/browser" });
Response.Cookies.Delete(BrowserAuthOptions.CsrfCookie, new CookieOptions { Secure = true, Path = "/" });
}
private void EnsureTrustedOrigin()
{
var origin = Request.Headers.Origin.ToString();
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Scheme, Request.Scheme, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(uri.Authority, Request.Host.Value, StringComparison.OrdinalIgnoreCase))
throw new BrowserOriginException();
}
private async Task<Guid?> ResolveTenantIdAsync(AuthRealm realm, string? tenantCode,
CancellationToken cancellationToken)
{
if (realm == AuthRealm.Platform)
{
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.");
return null;
}
if (tenantContext.TenantId is { } resolved) return resolved;
if (string.IsNullOrWhiteSpace(tenantCode)) throw new RequiredFieldException("tenantCode is required.");
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
?? throw new TenantNotFoundException();
tenantContextInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
private static void EnsureTenantRealm(AuthRealm realm)
{
if (realm != AuthRealm.Tenant)
throw new RequiredFieldException("Browser tenant authentication only accepts the tenant realm.");
}
}
public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request.");