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) : ControllerBase { [AllowAnonymous] [EnableRateLimiting(AuthRateLimitPolicies.Sms)] [HttpPost("sms/send")] [ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status429TooManyRequests)] public async Task> 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(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task> 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] [HttpPost("login/sms")] [EndpointSummary("短信验证码登录")] [EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task> 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(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] public async Task> 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(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] public async Task> 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> 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 Logout( [FromBody] RefreshSessionDto request, CancellationToken cancellationToken) { ResolveRefreshTokenTenant(request.RefreshToken); await authService.LogoutAsync( new LogoutSessionRequest(request.RefreshToken), cancellationToken); return NoContent(); } [HttpPost("logout-all")] [Authorize] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task 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)] public async Task> 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 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."); } } }