Files
tiku-backend.net/Tiku.Api/Controllers/AuthController.cs

326 lines
14 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 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(
IAuthService authService,
IOwnerActivationService ownerActivationService,
ISmsVerificationService smsVerificationService,
AuthRequestContextResolver requestContextResolver,
ICurrentUser currentUser) : ControllerBase
{
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
[HttpPost("activation/complete")]
[EndpointSummary("完成租户负责人一次性激活")]
[EndpointDescription("使用平台开通时签发的一次性令牌设置初始密码;令牌仅可消费一次。")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CompleteOwnerActivation(
CompleteOwnerActivationDto request,
CancellationToken cancellationToken)
{
await ownerActivationService.CompleteAsync(request.ToRequest(), cancellationToken);
return NoContent();
}
[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;
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<AuthenticationResultDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<AuthenticationResultDto>> 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 authService.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<AuthenticationResultDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<AuthenticationResultDto>> LoginWithSms(
[FromBody] SmsLoginDto request,
CancellationToken cancellationToken)
{
var realm = request.Realm!.Value;
requestContextResolver.EnsureRouteRealm(realm, Request);
var result = await authService.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/unionidupsert 用户身份并创建应用会话。")]
[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;
requestContextResolver.EnsureRouteRealm(realm, Request);
var result = await authService.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_keyupsert 用户身份并创建应用会话。")]
[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;
requestContextResolver.EnsureRouteRealm(realm, Request);
var result = await authService.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<ActionResult<AuthTokenPair>> Refresh(
[FromBody] RefreshSessionDto request,
CancellationToken cancellationToken)
{
requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request);
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 对应的数据库 sessionsession 校验开启时,旧 access token 也会被拒绝。")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout(
[FromBody] RefreshSessionDto request,
CancellationToken cancellationToken)
{
requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request);
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)
{
requestContextResolver.ResolveAuthChallengeTenant(request.ChallengeToken, Request);
var result = await authService.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<SmsSendResult>(StatusCodes.Status202Accepted)]
public async Task<ActionResult<SmsSendResult>> 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 authService.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<IActionResult> 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 authService.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<ActionResult<AuthenticationResultDto>> ChangePassword(
AuthenticatedPasswordChangeDto request,
CancellationToken cancellationToken)
{
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,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
cancellationToken);
return Ok(AuthenticationResultDto.FromApplication(result));
}
private string? GetIpAddress()
{
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
}