登录验证码接入
This commit is contained in:
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);
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user