using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; 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/platform/auth")] [Route("api/tenant/auth")] [Route("api/student/auth")] [Produces("application/json")] public sealed class AuthController( AuthCapabilitySet authCapabilities, IOwnerActivationService ownerActivationService, ISmsVerificationService smsVerificationService, AuthRequestContextResolver requestContextResolver, ICurrentUser currentUser) : ControllerBase { [AllowAnonymous] [EnableRateLimiting(AuthRateLimitPolicies.Password)] [HttpPost("activation/complete")] [EndpointSummary("完成租户负责人一次性激活")] [EndpointDescription("使用平台开通时签发的一次性令牌设置初始密码;令牌仅可消费一次。")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task CompleteOwnerActivation( CompleteOwnerActivationDto request, CancellationToken cancellationToken) { await ownerActivationService.CompleteAsync(request.ToRequest(), cancellationToken); return NoContent(); } [AllowAnonymous] [EnableRateLimiting(AuthRateLimitPolicies.Sms)] [HttpPost("sms/send")] [EndpointSummary("发送短信验证码")] [EndpointDescription("发送登录用途短信验证码,并应用租户级短信限流。")] [ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status429TooManyRequests)] public async Task> SendSmsCode( [FromBody] SendSmsCodeDto request, CancellationToken cancellationToken) { var realm = request.Realm!.Value; requestContextResolver.EnsureRouteRealm(realm, Request); if (realm != AuthRealm.Tenant) throw new RequiredFieldException("SMS authentication is only available in the tenant realm."); var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request, 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; requestContextResolver.EnsureRouteRealm(realm, Request); var identifier = request.Identifier ?? request.Phone; if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required."); var result = await authCapabilities.PasswordLogin.LoginWithPasswordAsync( new PasswordLoginRequest( realm, await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request, 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(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task> LoginWithSms( [FromBody] SmsLoginDto request, CancellationToken cancellationToken) { var realm = request.Realm!.Value; requestContextResolver.EnsureRouteRealm(realm, Request); var result = await authCapabilities.SmsLogin.LoginWithSmsAsync( new SmsLoginRequest( realm, await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request, 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; requestContextResolver.EnsureRouteRealm(realm, Request); var result = await authCapabilities.WechatLogin.LoginWithWechatWebAsync( new WechatLoginRequest( realm, await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request, 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; requestContextResolver.EnsureRouteRealm(realm, Request); var result = await authCapabilities.WechatLogin.LoginWithWechatMiniAppAsync( new WechatLoginRequest( realm, await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request, 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) { requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request); var result = await authCapabilities.Sessions.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) { requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request); await authCapabilities.Sessions.LogoutAsync( new LogoutSessionRequest(request.RefreshToken), cancellationToken); return NoContent(); } [HttpPost("logout-all")] [Authorize] [EndpointSummary("退出全部登录会话")] [EndpointDescription("撤销当前用户全部 refresh/session,会话校验开启时旧 access token 也会被拒绝。")] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task LogoutAll(CancellationToken cancellationToken) { if (currentUser.UserId is not { } userId) return Unauthorized(); await authCapabilities.Sessions.LogoutAllAsync(userId, cancellationToken); return NoContent(); } [AllowAnonymous] [HttpPost("password/change-required")] [EnableRateLimiting(AuthRateLimitPolicies.Password)] [EndpointSummary("修改首次登录必改密码")] [EndpointDescription("校验密码变更挑战令牌并设置新密码,成功后签发新的登录会话。")] public async Task> ChangeRequiredPassword( [FromBody] RequiredPasswordChangeDto request, CancellationToken cancellationToken) { requestContextResolver.ResolveAuthChallengeTenant(request.ChallengeToken, Request); var result = await authCapabilities.PasswordLifecycle.ChangeRequiredPasswordAsync( new PasswordChangeChallengeRequest( request.ChallengeToken, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()), cancellationToken); return Ok(AuthenticationResultDto.FromApplication(result)); } [AllowAnonymous] [EnableRateLimiting(AuthRateLimitPolicies.Sms)] [HttpPost("password/reset/sms/send")] [EndpointSummary("发送密码重置短信验证码")] [EndpointDescription("仅适用于租户授权域;无论手机号是否存在均返回相同接受响应。")] [ProducesResponseType(StatusCodes.Status202Accepted)] public async Task> SendPasswordResetCode( PasswordResetSmsSendDto request, CancellationToken cancellationToken) { var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, Request, cancellationToken) ?? throw new RequiredFieldException("tenantCode is required for password reset."); var result = await authCapabilities.PasswordLifecycle.RequestPasswordResetAsync( new PasswordResetCodeRequest( tenantId, request.Phone, GetIpAddress(), 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) { var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode, Request, cancellationToken) ?? throw new RequiredFieldException("tenantCode is required for password reset."); await authCapabilities.PasswordLifecycle.ResetPasswordAsync( new PasswordResetRequest( tenantId, request.Phone, request.Code, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()), cancellationToken); return NoContent(); } [Authorize] [EnableRateLimiting(AuthRateLimitPolicies.Password)] [HttpPost("password/change")] [EndpointSummary("已登录用户修改密码")] [EndpointDescription("修改成功后撤销旧会话并返回新的令牌对。")] public async Task> ChangePassword( AuthenticatedPasswordChangeDto request, CancellationToken cancellationToken) { if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized(); var result = await authCapabilities.PasswordLifecycle.ChangePasswordAsync( new AuthenticatedPasswordChangeRequest( userId, sessionId, request.CurrentPassword, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()), cancellationToken); return Ok(AuthenticationResultDto.FromApplication(result)); } private string? GetIpAddress() { return HttpContext.Connection.RemoteIpAddress?.ToString(); } }