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

252 lines
9.8 KiB
C#

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]
[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")]
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")]
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")]
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")]
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")]
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")]
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")]
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")]
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.");