登录验证码接入

This commit is contained in:
biss committed 2026-09-11 22:05:07 +08:00
1 parent ae075bda6d
commit f489a3553c
15 files changed
+471 -19

No files matched your search

@@ -20,6 +20,7 @@ public sealed class AuthController(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
IAuthSessionService authSessionService, IAuthSessionService authSessionService,
ITwoFactorLoginTicketService twoFactorTickets, ITwoFactorLoginTicketService twoFactorTickets,
ILoginCaptchaService loginCaptcha,
IAppCache cache) : ControllerBase IAppCache cache) : ControllerBase
{ {
[AllowAnonymous] [AllowAnonymous]
@@ -135,6 +136,35 @@ public sealed class AuthController(
}); });
} }
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("login/captcha")]
public async Task<ActionResult<LoginCaptchaChallenge>> CreateLoginCaptcha(
LoginCaptchaCreateRequest request,
CancellationToken cancellationToken) =>
Ok(await loginCaptcha.CreateAsync(request.DeviceId, HttpContext, cancellationToken));
[AllowAnonymous]
[EnableRateLimiting("public-auth")]
[HttpPost("login/captcha/verify")]
public async Task<ActionResult<LoginCaptchaProofResponse>> VerifyLoginCaptcha(
LoginCaptchaVerifyRequest request,
CancellationToken cancellationToken)
{
var proof = await loginCaptcha.VerifyAsync(
new LoginCaptchaVerification(
request.ChallengeId,
request.DeviceId,
request.Text,
request.SliderX,
request.Clicks),
HttpContext,
cancellationToken);
return proof is null
? Unauthorized(LoginCaptchaProblem())
: Ok(new LoginCaptchaProofResponse(proof));
}
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting("public-auth")] [EnableRateLimiting("public-auth")]
[HttpPost("login")] [HttpPost("login")]
@@ -142,6 +172,12 @@ public sealed class AuthController(
LoginRequest request, LoginRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!await loginCaptcha.ConsumeProofAsync(
request.CaptchaTicket,
request.DeviceId,
HttpContext,
cancellationToken))
return Unauthorized(LoginCaptchaProblem());
var user = await userManager.FindByNameAsync(request.UserName); var user = await userManager.FindByNameAsync(request.UserName);
if (user is null || !user.IsEnabled) if (user is null || !user.IsEnabled)
{ {
@@ -290,6 +326,12 @@ public sealed class AuthController(
Status = StatusCodes.Status401Unauthorized Status = StatusCodes.Status401Unauthorized
}; };
internal static ProblemDetails LoginCaptchaProblem() => new()
{
Title = "安全验证失败", Detail = "验证码无效、已过期或与当前设备不匹配,请重新验证。",
Status = StatusCodes.Status401Unauthorized
};
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) => internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
new( new(
session.AccessToken, session.AccessToken,
@@ -308,8 +350,21 @@ public sealed class AuthController(
public sealed record LoginRequest( public sealed record LoginRequest(
[Required, MaxLength(100)] string UserName, [Required, MaxLength(100)] string UserName,
[Required, MaxLength(100)] string Password, [Required, MaxLength(100)] string Password,
[Required, MinLength(32), MaxLength(128)] string CaptchaTicket,
[MaxLength(128)] string? DeviceId = null,
bool IsNativeApp = false); bool IsNativeApp = false);
public sealed record LoginCaptchaCreateRequest([MaxLength(128)] string? DeviceId = null);
public sealed record LoginCaptchaVerifyRequest(
[Required, MinLength(32), MaxLength(64)] string ChallengeId,
[MaxLength(128)] string? DeviceId,
[MaxLength(16)] string? Text,
[Range(0, 240)] int? SliderX,
[MaxLength(2)] IReadOnlyList<CaptchaPoint>? Clicks);
public sealed record LoginCaptchaProofResponse(string CaptchaTicket);
public sealed record TotpLoginRequest( public sealed record TotpLoginRequest(
[Required, MinLength(20), MaxLength(2048)] string TwoFactorTicket, [Required, MinLength(20), MaxLength(2048)] string TwoFactorTicket,
[Required, MinLength(6), MaxLength(12)] string Code); [Required, MinLength(6), MaxLength(12)] string Code);
+12 -1
View File
@@ -22,6 +22,7 @@ public sealed class SsoController(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
IAuthSessionService authSessionService, IAuthSessionService authSessionService,
IDistributedCache cache, IDistributedCache cache,
ILoginCaptchaService loginCaptcha,
IOptions<SsoOptions> options, IOptions<SsoOptions> options,
ILogger<SsoController> logger) : ControllerBase ILogger<SsoController> logger) : ControllerBase
{ {
@@ -46,12 +47,20 @@ public sealed class SsoController(
[FromQuery] string? bindingIntent = null, [FromQuery] string? bindingIntent = null,
[FromQuery] bool nativeApp = false, [FromQuery] bool nativeApp = false,
[FromQuery] string? nativeState = null, [FromQuery] string? nativeState = null,
[FromQuery] string? captchaTicket = null,
[FromQuery] string? deviceId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!_options.Enabled) if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var safeReturnUrl = NormalizeReturnUrl(returnUrl); var safeReturnUrl = NormalizeReturnUrl(returnUrl);
if (string.IsNullOrWhiteSpace(bindingIntent) &&
(string.IsNullOrWhiteSpace(captchaTicket) ||
!await loginCaptcha.ConsumeProofAsync(captchaTicket, deviceId, HttpContext, cancellationToken)))
{
return Unauthorized(AuthController.LoginCaptchaProblem());
}
var properties = new AuthenticationProperties(); var properties = new AuthenticationProperties();
if (nativeApp) if (nativeApp)
{ {
@@ -407,7 +416,9 @@ public sealed class SsoController(
returnUrl = "/account", returnUrl = "/account",
bindingIntent = intentCode, bindingIntent = intentCode,
nativeApp, nativeApp,
nativeState nativeState,
captchaTicket = (string?)null,
deviceId = (string?)null
})!; })!;
return new SsoBindingStartResponse(loginUrl); return new SsoBindingStartResponse(loginUrl);
} }
@@ -0,0 +1,160 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Distributed;
namespace Jiaowu.Api.Infrastructure.Auth;
public enum LoginCaptchaKind
{
Text,
Slider,
Click
}
public interface ILoginCaptchaService
{
Task<LoginCaptchaChallenge> CreateAsync(string? deviceId, HttpContext context, CancellationToken cancellationToken);
Task<string?> VerifyAsync(LoginCaptchaVerification verification, HttpContext context, CancellationToken cancellationToken);
Task<bool> ConsumeProofAsync(string proof, string? deviceId, HttpContext context, CancellationToken cancellationToken);
}
public sealed class LoginCaptchaService(IDistributedCache cache) : ILoginCaptchaService
{
private const int CaptchaLifetimeSeconds = 120;
private static readonly char[] TextAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".ToCharArray();
private static readonly string[] ClickAlphabet = ["春", "夏", "秋", "冬", "山", "水", "云", "月", "书", "院"];
public async Task<LoginCaptchaChallenge> CreateAsync(
string? deviceId,
HttpContext context,
CancellationToken cancellationToken)
{
var kind = (LoginCaptchaKind)RandomNumberGenerator.GetInt32(0, 3);
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
var binding = CreateBinding(deviceId, context);
var state = kind switch
{
LoginCaptchaKind.Text => CreateTextState(id, binding),
LoginCaptchaKind.Slider => CreateSliderState(id, binding),
_ => CreateClickState(id, binding)
};
await cache.SetStringAsync(
ChallengeKey(id),
JsonSerializer.Serialize(state),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(CaptchaLifetimeSeconds)
},
cancellationToken);
return new LoginCaptchaChallenge(
state.Id,
state.Kind.ToString(),
CaptchaLifetimeSeconds,
state.ImageSvg,
state.Prompt);
}
public async Task<string?> VerifyAsync(
LoginCaptchaVerification verification,
HttpContext context,
CancellationToken cancellationToken)
{
var cacheKey = ChallengeKey(verification.ChallengeId);
var json = await cache.GetStringAsync(cacheKey, cancellationToken);
await cache.RemoveAsync(cacheKey, cancellationToken);
LoginCaptchaState? state;
try { state = json is null ? null : JsonSerializer.Deserialize<LoginCaptchaState>(json); }
catch (JsonException) { state = null; }
if (state is null || !FixedEquals(state.Binding, CreateBinding(verification.DeviceId, context)))
return null;
var valid = state.Kind switch
{
LoginCaptchaKind.Text => FixedEquals(state.Answer, NormalizeText(verification.Text)),
LoginCaptchaKind.Slider => verification.SliderX is not null && Math.Abs(state.SliderX!.Value - verification.SliderX.Value) <= 6,
LoginCaptchaKind.Click => ClicksMatch(state.Clicks!, verification.Clicks),
_ => false
};
if (!valid) return null;
var proof = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync(
ProofKey(proof),
state.Binding,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(CaptchaLifetimeSeconds)
},
cancellationToken);
return proof;
}
public async Task<bool> ConsumeProofAsync(
string proof,
string? deviceId,
HttpContext context,
CancellationToken cancellationToken)
{
var cacheKey = ProofKey(proof);
var binding = await cache.GetStringAsync(cacheKey, cancellationToken);
await cache.RemoveAsync(cacheKey, cancellationToken);
return binding is not null && FixedEquals(binding, CreateBinding(deviceId, context));
}
private static LoginCaptchaState CreateTextState(string id, string binding)
{
var text = string.Concat(Enumerable.Range(0, 5).Select(_ => TextAlphabet[RandomNumberGenerator.GetInt32(TextAlphabet.Length)]));
var svg = $"<svg xmlns='http://www.w3.org/2000/svg' width='240' height='76' viewBox='0 0 240 76'><rect width='240' height='76' rx='8' fill='#f4f7fb'/><path d='M0 22 C50 8 105 44 240 17 M0 58 C72 30 150 74 240 46' stroke='#b9c9dd' stroke-width='2' fill='none'/><text x='120' y='51' text-anchor='middle' font-family='monospace' font-size='35' font-weight='700' letter-spacing='8' fill='#1f456c'>{text}</text></svg>";
return new LoginCaptchaState(id, LoginCaptchaKind.Text, binding, NormalizeText(text), null, null, SvgDataUri(svg), "请输入图中的 5 位字符");
}
private static LoginCaptchaState CreateSliderState(string id, string binding)
{
var x = RandomNumberGenerator.GetInt32(54, 181);
var hue = RandomNumberGenerator.GetInt32(185, 240);
var svg = $"<svg xmlns='http://www.w3.org/2000/svg' width='280' height='132' viewBox='0 0 280 132'><defs><linearGradient id='g' x1='0' x2='1'><stop stop-color='hsl({hue} 62% 58%)'/><stop offset='1' stop-color='hsl({hue + 35} 70% 72%)'/></linearGradient></defs><rect width='280' height='132' rx='9' fill='url(#g)'/><path d='M0 98 Q58 60 111 93 T218 72 T280 82V132H0Z' fill='#ffffff55'/><circle cx='{x + 20}' cy='60' r='17' fill='#12395a66'/><rect x='{x}' y='42' width='40' height='36' rx='6' fill='#0d355666'/><text x='140' y='116' text-anchor='middle' font-family='sans-serif' font-size='13' fill='white'>将滑块拖到缺口处</text></svg>";
return new LoginCaptchaState(id, LoginCaptchaKind.Slider, binding, string.Empty, x, null, SvgDataUri(svg), "拖动滑块,使拼图对准缺口");
}
private static LoginCaptchaState CreateClickState(string id, string binding)
{
var labels = ClickAlphabet.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)).Take(4).ToArray();
var positions = new[] { (45, 42), (116, 48), (192, 43), (81, 98) };
var targets = labels.Take(2).ToArray();
var svgLabels = string.Join(string.Empty, labels.Select((label, index) =>
$"<circle cx='{positions[index].Item1}' cy='{positions[index].Item2}' r='22' fill='#e7f1ff'/><text x='{positions[index].Item1}' y='{positions[index].Item2 + 8}' text-anchor='middle' font-size='23' fill='#164a7a'>{label}</text>"));
var svg = $"<svg xmlns='http://www.w3.org/2000/svg' width='240' height='132' viewBox='0 0 240 132'><rect width='240' height='132' rx='9' fill='#f6f9fd'/><path d='M0 22H240M0 88H240' stroke='#d3e1f0'/>{svgLabels}</svg>";
var clicks = targets.Select(target =>
{
var index = Array.IndexOf(labels, target);
return new CaptchaPoint(positions[index].Item1, positions[index].Item2);
}).ToArray();
return new LoginCaptchaState(id, LoginCaptchaKind.Click, binding, string.Empty, null, clicks, SvgDataUri(svg), $"请依次点击「{targets[0]}」「{targets[1]}」");
}
private static bool ClicksMatch(IReadOnlyList<CaptchaPoint> expected, IReadOnlyList<CaptchaPoint>? actual) =>
actual is { Count: 2 } && expected.Count == actual.Count && expected.Zip(actual).All(pair =>
Math.Abs(pair.First.X - pair.Second.X) <= 18 && Math.Abs(pair.First.Y - pair.Second.Y) <= 18);
private static string NormalizeText(string? value) => (value ?? string.Empty).Trim().ToUpperInvariant();
private static string CreateBinding(string? deviceId, HttpContext context)
{
var input = $"{context.Connection.RemoteIpAddress}|{deviceId?.Trim() ?? string.Empty}";
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input)));
}
private static bool FixedEquals(string left, string right) =>
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(left), Encoding.UTF8.GetBytes(right));
private static string SvgDataUri(string svg) => "data:image/svg+xml;base64," + Convert.ToBase64String(Encoding.UTF8.GetBytes(svg));
private static string ChallengeKey(string id) => $"auth:captcha:challenge:{id}";
private static string ProofKey(string id) => $"auth:captcha:proof:{id}";
}
public sealed record LoginCaptchaChallenge(string Id, string Kind, int ExpiresInSeconds, string ImageSvg, string Prompt);
public sealed record LoginCaptchaVerification(string ChallengeId, string? DeviceId, string? Text, int? SliderX, IReadOnlyList<CaptchaPoint>? Clicks);
public sealed record CaptchaPoint(int X, int Y);
internal sealed record LoginCaptchaState(string Id, LoginCaptchaKind Kind, string Binding, string Answer, int? SliderX, IReadOnlyList<CaptchaPoint>? Clicks, string ImageSvg, string Prompt);
Loaded 3 of 15 files, more files were not shown because too many files have changed in this diff. Show more