Files
tiku-backend.net/Tiku.Api/Controllers/BrowserAuthController.cs
xiong 4bea745b79 feat: Add tags and endpoint summaries to various controllers for better API documentation
- Added tags to BrowserAuthController for browser authentication endpoints.
- Added tags to CatalogController for public catalog access.
- Added tags to CommerceController for student transaction operations.
- Added tags to CommissionController for tenant commission management.
- Added tags to CrmController for tenant CRM functionalities.
- Added tags to HealthController for system health checks.
- Added tags to LearningController for student learning resources.
- Added tags to MeController for current user information.
- Added tags to PlatformAdminController for platform management.
- Introduced PlatformBackofficeController for backend permissions management.
- Added tags to PlatformBillingCallbackController for billing callbacks.
- Added tags to PlatformPaymentSettingsController for payment settings management.
- Added tags to PlatformSaasController for SaaS package management.
- Added tags to PlatformTenantCapabilitiesController for tenant capabilities.
- Added tags to PointsController for student points management.
- Added tags to ProfileController for student profile management.
- Added tags to QuestionVideosController for question video resources.
- Added tags to ReferralController for referral growth management.
- Added tags to RuntimeController for runtime configurations.
- Added tags to ScorelineController for scoreline management.
- Added tags to TaxonomyController for category management.
- Added tags to TenantAdminDirectController for tenant operations management.
- Introduced TenantBackofficeController for tenant backend permissions.
- Added tags to TenantBillingController for tenant billing operations.
- Added tags to TenantCommerceController for tenant commerce operations.
- Added tags to TenantContentController for tenant content management.
- Added tags to TenantContentDirectController for direct content management.
- Added tags to TenantFrontendConfigController for frontend configurations.
- Added tags to TenantOnboardingController for onboarding guidance.
- Added tags to TenantPublicController for public tenant configurations.
- Added tags to TenantsController for current tenant information.
- Added tags to VideosController for student video resources.
2026-07-29 16:16:27 +08:00

263 lines
10 KiB
C#
Raw Permalink 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 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,
ISmsVerificationService smsVerificationService,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
ITenantDirectory tenantDirectory,
ICurrentUser currentUser,
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
{
[AllowAnonymous]
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
[HttpPost("sms/send")]
[EndpointSummary("发送浏览器短信验证码")]
public async Task<ActionResult<SmsSendResult>> 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<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<object>> 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<ActionResult<object>> 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<IActionResult> 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<IActionResult> LogoutAll(CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId) return Unauthorized();
await authService.LogoutAllAsync(userId, cancellationToken);
ClearCookies();
return NoContent();
}
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<Guid?> 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.");