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.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Content; namespace Tiku.Api.Controllers; [ApiController] [Tags("租户端-浏览器认证")] [Route("api/browser-auth")] [Produces("application/json")] public sealed class BrowserAuthController( IAuthService authService, IOwnerActivationService ownerActivationService, ISmsVerificationService smsVerificationService, ITenantContext tenantContext, ITenantContextInitializer tenantContextInitializer, ITenantDirectory tenantDirectory, ICurrentUser currentUser, IOptions tenantResolutionOptions) : ControllerBase { [AllowAnonymous] [EnableRateLimiting(AuthRateLimitPolicies.Password)] [HttpPost("activation/complete")] [EndpointSummary("完成租户 Owner 激活并建立浏览器会话")] public async Task> 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> SendSmsCode( [FromBody] SendSmsCodeDto request, CancellationToken cancellationToken) { EnsureTrustedOrigin(); var realm = request.Realm!.Value; 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> LoginWithPassword( [FromBody] PasswordLoginDto request, CancellationToken cancellationToken) { EnsureTrustedOrigin(); 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 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> LoginWithSms( [FromBody] SmsLoginDto request, CancellationToken cancellationToken) { EnsureTrustedOrigin(); var realm = request.Realm!.Value; 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> LoginWithWechatWeb( [FromBody] OAuthCodeDto request, CancellationToken cancellationToken) { EnsureTrustedOrigin(); var realm = request.Realm!.Value; 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> LoginWithWechatMiniApp( [FromBody] OAuthCodeDto request, CancellationToken cancellationToken) { EnsureTrustedOrigin(); var realm = request.Realm!.Value; 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> 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 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 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> 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 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> 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/browser-auth", 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/browser-auth" }); 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 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; } } public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request.");