326 lines
14 KiB
C#
326 lines
14 KiB
C#
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<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 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<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 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<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 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<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 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<ActionResult<AuthTokenPair>> 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<IActionResult> 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<IActionResult> 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<ActionResult<AuthenticationResultDto>> 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<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 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<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 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<ActionResult<AuthenticationResultDto>> 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();
|
||
}
|
||
|
||
}
|