From f489a3553c12b3c94fecd615201d5815dc74e925 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 11 Sep 2026 22:05:07 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=AA=8C=E8=AF=81=E7=A0=81?= =?UTF-8?q?=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Jiaowu.Api/Controllers/AuthController.cs | 55 ++++++ src/Jiaowu.Api/Controllers/SsoController.cs | 13 +- .../Auth/LoginCaptchaService.cs | 160 ++++++++++++++++++ src/Jiaowu.Api/Program.cs | 1 + tests/Jiaowu.Api.Tests/AuthControllerTests.cs | 2 + .../LoginCaptchaServiceTests.cs | 61 +++++++ tests/Jiaowu.Api.Tests/SsoControllerTests.cs | 1 + web/package-lock.json | 7 + web/package.json | 1 + web/src/api/http.ts | 27 ++- web/src/auth/deviceFingerprint.ts | 13 ++ web/src/components.d.ts | 1 + web/src/components/LoginCaptcha.vue | 121 +++++++++++++ web/src/stores/auth.ts | 9 +- web/src/views/LoginView.vue | 18 +- 15 files changed, 471 insertions(+), 19 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Auth/LoginCaptchaService.cs create mode 100644 tests/Jiaowu.Api.Tests/LoginCaptchaServiceTests.cs create mode 100644 web/src/auth/deviceFingerprint.ts create mode 100644 web/src/components/LoginCaptcha.vue diff --git a/src/Jiaowu.Api/Controllers/AuthController.cs b/src/Jiaowu.Api/Controllers/AuthController.cs index eda8142..d4dce77 100644 --- a/src/Jiaowu.Api/Controllers/AuthController.cs +++ b/src/Jiaowu.Api/Controllers/AuthController.cs @@ -20,6 +20,7 @@ public sealed class AuthController( UserManager userManager, IAuthSessionService authSessionService, ITwoFactorLoginTicketService twoFactorTickets, + ILoginCaptchaService loginCaptcha, IAppCache cache) : ControllerBase { [AllowAnonymous] @@ -135,6 +136,35 @@ public sealed class AuthController( }); } + [AllowAnonymous] + [EnableRateLimiting("public-auth")] + [HttpPost("login/captcha")] + public async Task> CreateLoginCaptcha( + LoginCaptchaCreateRequest request, + CancellationToken cancellationToken) => + Ok(await loginCaptcha.CreateAsync(request.DeviceId, HttpContext, cancellationToken)); + + [AllowAnonymous] + [EnableRateLimiting("public-auth")] + [HttpPost("login/captcha/verify")] + public async Task> 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] [EnableRateLimiting("public-auth")] [HttpPost("login")] @@ -142,6 +172,12 @@ public sealed class AuthController( LoginRequest request, CancellationToken cancellationToken) { + if (!await loginCaptcha.ConsumeProofAsync( + request.CaptchaTicket, + request.DeviceId, + HttpContext, + cancellationToken)) + return Unauthorized(LoginCaptchaProblem()); var user = await userManager.FindByNameAsync(request.UserName); if (user is null || !user.IsEnabled) { @@ -290,6 +326,12 @@ public sealed class AuthController( Status = StatusCodes.Status401Unauthorized }; + internal static ProblemDetails LoginCaptchaProblem() => new() + { + Title = "安全验证失败", Detail = "验证码无效、已过期或与当前设备不匹配,请重新验证。", + Status = StatusCodes.Status401Unauthorized + }; + internal static LoginResponse CreateLoginResponse(AuthSessionResult session) => new( session.AccessToken, @@ -308,8 +350,21 @@ public sealed class AuthController( public sealed record LoginRequest( [Required, MaxLength(100)] string UserName, [Required, MaxLength(100)] string Password, + [Required, MinLength(32), MaxLength(128)] string CaptchaTicket, + [MaxLength(128)] string? DeviceId = null, 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? Clicks); + +public sealed record LoginCaptchaProofResponse(string CaptchaTicket); + public sealed record TotpLoginRequest( [Required, MinLength(20), MaxLength(2048)] string TwoFactorTicket, [Required, MinLength(6), MaxLength(12)] string Code); diff --git a/src/Jiaowu.Api/Controllers/SsoController.cs b/src/Jiaowu.Api/Controllers/SsoController.cs index edaa78f..d3a8c34 100644 --- a/src/Jiaowu.Api/Controllers/SsoController.cs +++ b/src/Jiaowu.Api/Controllers/SsoController.cs @@ -22,6 +22,7 @@ public sealed class SsoController( UserManager userManager, IAuthSessionService authSessionService, IDistributedCache cache, + ILoginCaptchaService loginCaptcha, IOptions options, ILogger logger) : ControllerBase { @@ -46,12 +47,20 @@ public sealed class SsoController( [FromQuery] string? bindingIntent = null, [FromQuery] bool nativeApp = false, [FromQuery] string? nativeState = null, + [FromQuery] string? captchaTicket = null, + [FromQuery] string? deviceId = null, CancellationToken cancellationToken = default) { if (!_options.Enabled) return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); 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(); if (nativeApp) { @@ -407,7 +416,9 @@ public sealed class SsoController( returnUrl = "/account", bindingIntent = intentCode, nativeApp, - nativeState + nativeState, + captchaTicket = (string?)null, + deviceId = (string?)null })!; return new SsoBindingStartResponse(loginUrl); } diff --git a/src/Jiaowu.Api/Infrastructure/Auth/LoginCaptchaService.cs b/src/Jiaowu.Api/Infrastructure/Auth/LoginCaptchaService.cs new file mode 100644 index 0000000..4d11dc9 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Auth/LoginCaptchaService.cs @@ -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 CreateAsync(string? deviceId, HttpContext context, CancellationToken cancellationToken); + Task VerifyAsync(LoginCaptchaVerification verification, HttpContext context, CancellationToken cancellationToken); + Task 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 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 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(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 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 = $"{text}"; + 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 = $"将滑块拖到缺口处"; + 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) => + $"{label}")); + var svg = $"{svgLabels}"; + 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 expected, IReadOnlyList? 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? Clicks); +public sealed record CaptchaPoint(int X, int Y); +internal sealed record LoginCaptchaState(string Id, LoginCaptchaKind Kind, string Binding, string Answer, int? SliderX, IReadOnlyList? Clicks, string ImageSvg, string Prompt); diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index c8817e9..c0077ff 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -436,6 +436,7 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/tests/Jiaowu.Api.Tests/AuthControllerTests.cs b/tests/Jiaowu.Api.Tests/AuthControllerTests.cs index 0f40604..5b069b7 100644 --- a/tests/Jiaowu.Api.Tests/AuthControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/AuthControllerTests.cs @@ -109,6 +109,8 @@ public sealed class AuthControllerTests userManager, new StubAuthSessionService(), new TwoFactorLoginTicketService(new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider()), + new LoginCaptchaService(new Microsoft.Extensions.Caching.Distributed.MemoryDistributedCache( + Microsoft.Extensions.Options.Options.Create(new Microsoft.Extensions.Caching.Memory.MemoryDistributedCacheOptions()))), NoOpAppCache.Instance); var request = new StudentActivationRequest( student.Name, diff --git a/tests/Jiaowu.Api.Tests/LoginCaptchaServiceTests.cs b/tests/Jiaowu.Api.Tests/LoginCaptchaServiceTests.cs new file mode 100644 index 0000000..e7bfaaf --- /dev/null +++ b/tests/Jiaowu.Api.Tests/LoginCaptchaServiceTests.cs @@ -0,0 +1,61 @@ +using System.Text; +using System.Text.RegularExpressions; +using Jiaowu.Api.Infrastructure.Auth; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Jiaowu.Api.Tests; + +public sealed class LoginCaptchaServiceTests +{ + [Fact] + public async Task Text_challenge_creates_a_one_time_proof_bound_to_the_same_device() + { + var cache = new MemoryDistributedCache( + Options.Create(new MemoryDistributedCacheOptions())); + var service = new LoginCaptchaService(cache); + var context = CreateContext("127.0.0.1"); + LoginCaptchaChallenge challenge; + do + { + challenge = await service.CreateAsync("device-hash", context, CancellationToken.None); + } while (challenge.Kind != nameof(LoginCaptchaKind.Text)); + + var svg = Encoding.UTF8.GetString(Convert.FromBase64String(challenge.ImageSvg.Split(',')[1])); + var text = Regex.Match(svg, "fill='#1f456c'>([A-Z0-9]+)").Groups[1].Value; + var proof = await service.VerifyAsync( + new LoginCaptchaVerification(challenge.Id, "device-hash", text, null, null), + context, + CancellationToken.None); + + Assert.NotNull(proof); + Assert.True(await service.ConsumeProofAsync(proof!, "device-hash", context, CancellationToken.None)); + Assert.False(await service.ConsumeProofAsync(proof!, "device-hash", context, CancellationToken.None)); + } + + [Fact] + public async Task Challenge_cannot_be_verified_from_a_different_device_binding() + { + var cache = new MemoryDistributedCache( + Options.Create(new MemoryDistributedCacheOptions())); + var service = new LoginCaptchaService(cache); + var context = CreateContext("127.0.0.1"); + var challenge = await service.CreateAsync("device-a", context, CancellationToken.None); + + var proof = await service.VerifyAsync( + new LoginCaptchaVerification(challenge.Id, "device-b", null, 0, []), + context, + CancellationToken.None); + + Assert.Null(proof); + } + + private static DefaultHttpContext CreateContext(string ip) + { + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse(ip); + return context; + } +} diff --git a/tests/Jiaowu.Api.Tests/SsoControllerTests.cs b/tests/Jiaowu.Api.Tests/SsoControllerTests.cs index 6a1658a..c624440 100644 --- a/tests/Jiaowu.Api.Tests/SsoControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/SsoControllerTests.cs @@ -187,6 +187,7 @@ public sealed class SsoControllerTests userManager, new StubAuthSessionService(), cache, + new LoginCaptchaService(cache), Options.Create(new SsoOptions { Enabled = true, diff --git a/web/package-lock.json b/web/package-lock.json index 05a6a2f..671af2d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,6 +17,7 @@ "@capgo/capacitor-updater": "^8.51.10", "@ckeditor/ckeditor5-vue": "^8.2.0", "@element-plus/icons-vue": "^2.3.2", + "@fingerprintjs/fingerprintjs": "^5.2.0", "axios": "^1.19.0", "ckeditor5": "^48.4.0", "dompurify": "^3.4.13", @@ -2674,6 +2675,12 @@ "vue": "^3.2.0" } }, + "node_modules/@fingerprintjs/fingerprintjs": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.2.0.tgz", + "integrity": "sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", diff --git a/web/package.json b/web/package.json index f8d82cb..5739823 100644 --- a/web/package.json +++ b/web/package.json @@ -26,6 +26,7 @@ "@capgo/capacitor-updater": "^8.51.10", "@ckeditor/ckeditor5-vue": "^8.2.0", "@element-plus/icons-vue": "^2.3.2", + "@fingerprintjs/fingerprintjs": "^5.2.0", "axios": "^1.19.0", "ckeditor5": "^48.4.0", "dompurify": "^3.4.13", diff --git a/web/src/api/http.ts b/web/src/api/http.ts index 90cff73..fa5fbaf 100644 --- a/web/src/api/http.ts +++ b/web/src/api/http.ts @@ -20,14 +20,16 @@ const http = axios.create({ timeout: 15000, }) +function isAuthenticationRequest(url?: string) { + return url?.startsWith('/auth/login') || + url?.endsWith('/auth/refresh') || + url?.endsWith('/auth/logout') || + url?.endsWith('/auth/sso/exchange') || + url?.endsWith('/auth/sso/bind') +} + http.interceptors.request.use(async (config) => { - const isAuthenticationRequest = - config.url?.endsWith('/auth/login') || - config.url?.endsWith('/auth/refresh') || - config.url?.endsWith('/auth/logout') || - config.url?.endsWith('/auth/sso/exchange') || - config.url?.endsWith('/auth/sso/bind') - if (!isAuthenticationRequest && !config.skipAuthSession) { + if (!isAuthenticationRequest(config.url) && !config.skipAuthSession) { const activeToken = await refreshIfNeeded(true) if (activeToken) markActivity() } @@ -39,15 +41,10 @@ http.interceptors.request.use(async (config) => { http.interceptors.response.use( (response) => response, async (error) => { - const isAuthenticationRequest = - error.config?.url?.endsWith('/auth/login') || - error.config?.url?.endsWith('/auth/refresh') || - error.config?.url?.endsWith('/auth/logout') || - error.config?.url?.endsWith('/auth/sso/exchange') || - error.config?.url?.endsWith('/auth/sso/bind') + const authenticationRequest = isAuthenticationRequest(error.config?.url) const retryableConfig = error.config as (typeof error.config & { _jiaowuRetried?: boolean }) | undefined - if (error.response?.status === 401 && !isAuthenticationRequest && !error.config?.skipAuthSession && + if (error.response?.status === 401 && !authenticationRequest && !error.config?.skipAuthSession && !retryableConfig?._jiaowuRetried) { const token = await refreshAuthSession() if (token && retryableConfig) { @@ -56,7 +53,7 @@ http.interceptors.response.use( return http.request(retryableConfig) } } - if (error.response?.status === 401 && !isAuthenticationRequest && !error.config?.skipAuthSession) { + if (error.response?.status === 401 && !authenticationRequest && !error.config?.skipAuthSession) { clearAuthSession() goLogin(location.pathname + location.search + location.hash) } diff --git a/web/src/auth/deviceFingerprint.ts b/web/src/auth/deviceFingerprint.ts new file mode 100644 index 0000000..49fa37d --- /dev/null +++ b/web/src/auth/deviceFingerprint.ts @@ -0,0 +1,13 @@ +import FingerprintJS from '@fingerprintjs/fingerprintjs' + +const fingerprint = FingerprintJS.load() + +/** A pseudonymous, client-generated identifier used only to bind short-lived login challenges. */ +export async function getLoginDeviceId(): Promise { + try { + return (await fingerprint).get().then((result) => result.visitorId) + } catch { + // Privacy extensions may block fingerprinting. The captcha remains usable with IP/session binding. + return '' + } +} diff --git a/web/src/components.d.ts b/web/src/components.d.ts index a08b30d..3bd907b 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -61,6 +61,7 @@ declare module 'vue' { ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect'] ElUpload: typeof import('element-plus/es')['ElUpload'] + LoginCaptcha: typeof import('./components/LoginCaptcha.vue')['default'] NativePlaceCascader: typeof import('./components/NativePlaceCascader.vue')['default'] NotificationRichTextEditor: typeof import('./components/NotificationRichTextEditor.vue')['default'] PerformanceReportPanel: typeof import('./components/PerformanceReportPanel.vue')['default'] diff --git a/web/src/components/LoginCaptcha.vue b/web/src/components/LoginCaptcha.vue new file mode 100644 index 0000000..ea1ff6c --- /dev/null +++ b/web/src/components/LoginCaptcha.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index de8a7f2..04cbe82 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -28,10 +28,17 @@ export const useAuthStore = defineStore('auth', () => { const isLoggedIn = computed(() => Boolean(token.value)) const isSuperAdmin = computed(() => user.value?.roles.includes('SuperAdmin') ?? false) - async function login(userName: string, password: string): Promise<{ twoFactorTicket?: string }> { + async function login( + userName: string, + password: string, + captchaTicket: string, + deviceId: string, + ): Promise<{ twoFactorTicket?: string }> { const { data } = await http.post('/auth/login', { userName, password, + captchaTicket, + deviceId, isNativeApp: isNativeApp(), }) if (data.requiresTotp) return { twoFactorTicket: String(data.twoFactorTicket) } diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index 8dd3489..0c26c25 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -6,12 +6,16 @@ import { useAuthStore } from '../stores/auth' import http from '../api/http' import { isNativeApp } from '../auth/session' import { beginNativeSsoLogin, openSsoLogin } from '../services/nativeAppLinks' +import LoginCaptcha from '../components/LoginCaptcha.vue' const route = useRoute() const router = useRouter() const auth = useAuthStore() const loading = ref(false) const twoFactorTicket = ref('') +const captchaTicket = ref('') +const captchaDeviceId = ref('') +const captcha = ref<{ refresh: () => Promise } | null>(null) const ssoLoading = ref(false) const sso = reactive({ enabled: false, displayName: '学校统一身份认证' }) const form = reactive({ @@ -23,13 +27,15 @@ const form = reactive({ async function submit() { loading.value = true try { - const result = await auth.login(form.userName, form.password) + const result = await auth.login(form.userName, form.password, captchaTicket.value, captchaDeviceId.value) if (result.twoFactorTicket) { twoFactorTicket.value = result.twoFactorTicket return } await router.replace(String(route.query.redirect ?? '/dashboard')) } catch (error) { + captchaTicket.value = '' + await captcha.value?.refresh() ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false @@ -54,8 +60,9 @@ async function startSso() { const redirect = String(route.query.redirect ?? '/dashboard') const nativeState = beginNativeSsoLogin() try { + if (!captchaTicket.value) return await openSsoLogin( - `${apiBaseUrl}/auth/sso/login?returnUrl=${encodeURIComponent(redirect)}&nativeApp=${isNativeApp()}${nativeState ? `&nativeState=${encodeURIComponent(nativeState)}` : ''}`, + `${apiBaseUrl}/auth/sso/login?returnUrl=${encodeURIComponent(redirect)}&nativeApp=${isNativeApp()}&captchaTicket=${encodeURIComponent(captchaTicket.value)}&deviceId=${encodeURIComponent(captchaDeviceId.value)}${nativeState ? `&nativeState=${encodeURIComponent(nativeState)}` : ''}`, ) } catch (error) { ssoLoading.value = false @@ -132,12 +139,18 @@ onMounted(async () => { @keyup.enter="submit" /> + @@ -147,6 +160,7 @@ onMounted(async () => { class="sso-login-submit" size="large" :loading="ssoLoading" + :disabled="!captchaTicket" @click="startSso" > 使用{{ sso.displayName }}登录