From c1451d0e6d56d8b8addf985d6bc00009b4c4ccb2 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 12 Sep 2026 08:37:12 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=82=E9=85=8D=E7=AC=AC=E4=BA=8C=E7=89=88?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/CaptchaKit | 1 + src/Jiaowu.Api/Controllers/AuthController.cs | 20 +++++++-- src/Jiaowu.Api/Controllers/SsoController.cs | 2 +- .../CaptchaKitIntegrationTests.cs | 26 +++++++++-- web/src/components/LoginCaptcha.vue | 44 ++++++++++++++++--- 5 files changed, 79 insertions(+), 14 deletions(-) create mode 160000 packages/CaptchaKit diff --git a/packages/CaptchaKit b/packages/CaptchaKit new file mode 160000 index 0000000..9122699 --- /dev/null +++ b/packages/CaptchaKit @@ -0,0 +1 @@ +Subproject commit 9122699c8b9fb220c512e3546282dffa0719d2ee diff --git a/src/Jiaowu.Api/Controllers/AuthController.cs b/src/Jiaowu.Api/Controllers/AuthController.cs index dddcceb..189e420 100644 --- a/src/Jiaowu.Api/Controllers/AuthController.cs +++ b/src/Jiaowu.Api/Controllers/AuthController.cs @@ -142,10 +142,15 @@ public sealed class AuthController( [HttpPost("login/captcha")] public async Task> CreateLoginCaptcha(CancellationToken cancellationToken) { - var generated = await captcha.CreateAsync(cancellationToken); + var generated = await captcha.CreateAsync(cancellationToken: cancellationToken); return Ok(new LoginCaptchaResponse( generated.Id, - $"data:{generated.ContentType};base64,{Convert.ToBase64String(generated.ImageBytes)}")); + generated.Type.ToString(), + generated.Prompt, + generated.Choices?.Select(x => new LoginCaptchaChoice(x.Id, x.Label)).ToList(), + generated.ImageBytes is null || string.IsNullOrWhiteSpace(generated.ContentType) + ? null + : $"data:{generated.ContentType};base64,{Convert.ToBase64String(generated.ImageBytes)}")); } [AllowAnonymous] @@ -155,7 +160,7 @@ public sealed class AuthController( LoginRequest request, CancellationToken cancellationToken) { - if (!await captcha.VerifyAsync(request.CaptchaId, request.CaptchaCode, cancellationToken)) + if (!await captcha.VerifyAsync(request.CaptchaId, request.CaptchaCode, cancellationToken: cancellationToken)) return Unauthorized(LoginCaptchaProblem()); var user = await userManager.FindByNameAsync(request.UserName); if (user is null || !user.IsEnabled) @@ -333,7 +338,14 @@ public sealed record LoginRequest( [Required, MinLength(3), MaxLength(16)] string CaptchaCode, bool IsNativeApp = false); -public sealed record LoginCaptchaResponse(string CaptchaId, string ImageData); +public sealed record LoginCaptchaResponse( + string CaptchaId, + string Type, + string? Prompt, + IReadOnlyList? Choices, + string? ImageData); + +public sealed record LoginCaptchaChoice(string Id, string Label); public sealed record TotpLoginRequest( [Required, MinLength(20), MaxLength(2048)] string TwoFactorTicket, diff --git a/src/Jiaowu.Api/Controllers/SsoController.cs b/src/Jiaowu.Api/Controllers/SsoController.cs index aa6779e..67dd4ea 100644 --- a/src/Jiaowu.Api/Controllers/SsoController.cs +++ b/src/Jiaowu.Api/Controllers/SsoController.cs @@ -59,7 +59,7 @@ public sealed class SsoController( if (string.IsNullOrWhiteSpace(bindingIntent) && (string.IsNullOrWhiteSpace(captchaId) || string.IsNullOrWhiteSpace(captchaCode) || - !await captcha.VerifyAsync(captchaId, captchaCode, cancellationToken))) + !await captcha.VerifyAsync(captchaId, captchaCode, cancellationToken: cancellationToken))) { return Unauthorized(AuthController.LoginCaptchaProblem()); } diff --git a/tests/Jiaowu.Api.Tests/CaptchaKitIntegrationTests.cs b/tests/Jiaowu.Api.Tests/CaptchaKitIntegrationTests.cs index 6a328bd..ec1ec32 100644 --- a/tests/Jiaowu.Api.Tests/CaptchaKitIntegrationTests.cs +++ b/tests/Jiaowu.Api.Tests/CaptchaKitIntegrationTests.cs @@ -19,7 +19,7 @@ public sealed class CaptchaKitIntegrationTests var challenge = await captcha.CreateAsync(); Assert.Equal("image/png", challenge.ContentType); - Assert.True(challenge.ImageBytes.AsSpan().StartsWith(new byte[] { 137, 80, 78, 71 })); + Assert.True(challenge.ImageBytes!.AsSpan().StartsWith(new byte[] { 137, 80, 78, 71 })); Assert.True(await captcha.VerifyAsync(challenge.Id, "a1b2")); Assert.False(await captcha.VerifyAsync(challenge.Id, "a1b2")); } @@ -31,13 +31,33 @@ public sealed class CaptchaKitIntegrationTests new CaptchaGenerationRequest("test", DateTimeOffset.UtcNow.AddMinutes(1), 5)); Assert.Equal("image/png", challenge.Challenge.ContentType); - Assert.True(challenge.Challenge.ImageBytes.AsSpan().StartsWith(new byte[] { 137, 80, 78, 71 })); + Assert.True(challenge.Challenge.ImageBytes!.AsSpan().StartsWith(new byte[] { 137, 80, 78, 71 })); + } + + [Fact] + public void Random_generator_produces_each_enabled_challenge_type() + { + var generator = new RandomCaptchaGenerator(new CaptchaKitOptions()); + var types = new HashSet(); + for (var index = 0; index < 100; index++) + { + var generated = generator.Generate(new CaptchaGenerationRequest( + index.ToString(), DateTimeOffset.UtcNow.AddMinutes(1), 5)); + types.Add(generated.Challenge.Type); + if (generated.Challenge.Type == CaptchaChallengeType.Selection) + Assert.Contains(generated.Challenge.Choices!, x => x.Id == generated.Answer); + } + + Assert.Contains(CaptchaChallengeType.TextImage, types); + Assert.Contains(CaptchaChallengeType.Selection, types); + Assert.Contains(CaptchaChallengeType.OneClick, types); } private sealed class KnownGenerator : ICaptchaGenerator { public GeneratedCaptcha Generate(CaptchaGenerationRequest request) => new( - new CaptchaChallenge(request.Id, "image/png", new byte[] { 137, 80, 78, 71 }, request.ExpiresAt), + new CaptchaChallenge(request.Id, CaptchaChallengeType.TextImage, request.ExpiresAt, + "请输入图中的字符", null, "image/png", new byte[] { 137, 80, 78, 71 }), "A1B2"); } } diff --git a/web/src/components/LoginCaptcha.vue b/web/src/components/LoginCaptcha.vue index 8a2b518..0fdf822 100644 --- a/web/src/components/LoginCaptcha.vue +++ b/web/src/components/LoginCaptcha.vue @@ -2,13 +2,21 @@ import { onMounted, ref, watch } from 'vue' import http from '../api/http' -interface CaptchaImage { +interface CaptchaChoice { + id: string + label: string +} + +interface CaptchaChallenge { captchaId: string - imageData: string + type: 'TextImage' | 'Selection' | 'OneClick' + prompt?: string + choices?: CaptchaChoice[] + imageData?: string } const emit = defineEmits<{ change: [captchaId: string, captchaCode: string] }>() -const captcha = ref(null) +const captcha = ref(null) const code = ref('') const loading = ref(false) @@ -24,6 +32,9 @@ async function refresh() { } watch([captcha, code], () => emit('change', captcha.value?.captchaId ?? '', code.value)) +function choose(answer: string) { + code.value = answer +} onMounted(refresh) defineExpose({ refresh }) @@ -35,8 +46,26 @@ defineExpose({ refresh })
- 验证码图片,点击可更换 - + +
+

{{ captcha.prompt }}

+
+ + {{ choice.label }} + +
+ + 我确认是本人操作 + +
@@ -45,7 +74,10 @@ defineExpose({ refresh }) .login-captcha { display: grid; gap: 8px; } .captcha-heading { display: flex; justify-content: space-between; align-items: center; font-size: 14px; font-weight: 650; color: #233b54; } .captcha-heading button { border: 0; background: transparent; color: #467cae; cursor: pointer; font: inherit; } -.captcha-input { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 10px; align-items: center; } +.captcha-input { min-height: 50px; display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 10px; align-items: center; } .captcha-input img { width: 150px; height: 50px; object-fit: contain; border: 1px solid #d7e2ed; border-radius: 6px; cursor: pointer; background: #f7fafc; } +.captcha-question { grid-column: 1 / -1; display: grid; gap: 8px; padding: 10px 12px; border: 1px solid #d7e2ed; border-radius: 6px; background: #f7fafc; } +.captcha-question p { margin: 0; color: #233b54; font-size: 14px; } +.captcha-choices { display: flex; flex-wrap: wrap; gap: 8px; } @media (max-width: 420px) { .captcha-input { grid-template-columns: 130px minmax(0, 1fr); } .captcha-input img { width: 130px; } }