diff --git a/src/Jiaowu.Api/Controllers/ArchivesController.cs b/src/Jiaowu.Api/Controllers/ArchivesController.cs index f68a13f..b16967c 100644 --- a/src/Jiaowu.Api/Controllers/ArchivesController.cs +++ b/src/Jiaowu.Api/Controllers/ArchivesController.cs @@ -63,7 +63,7 @@ public sealed class ArchivesController(AppDbContext db, ICurrentUserDataScope sc } public sealed record ArchiveBatchRequest( - [property: Required, MinLength(1), MaxLength(100)] Guid[] Ids, + [param: Required, MinLength(1), MaxLength(100)] Guid[] Ids, bool Archived, - [property: MaxLength(500)] string? Reason = null); + [param: MaxLength(500)] string? Reason = null); diff --git a/src/Jiaowu.Api/Controllers/AuthController.cs b/src/Jiaowu.Api/Controllers/AuthController.cs index d4dce77..18bcc05 100644 --- a/src/Jiaowu.Api/Controllers/AuthController.cs +++ b/src/Jiaowu.Api/Controllers/AuthController.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; +using Lazy.Captcha.Core; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -20,7 +21,7 @@ public sealed class AuthController( UserManager userManager, IAuthSessionService authSessionService, ITwoFactorLoginTicketService twoFactorTickets, - ILoginCaptchaService loginCaptcha, + ICaptcha captcha, IAppCache cache) : ControllerBase { [AllowAnonymous] @@ -139,30 +140,13 @@ 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) + public ActionResult CreateLoginCaptcha() { - 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)); + var captchaId = Guid.NewGuid().ToString("N"); + var generated = captcha.Generate(captchaId, expirySeconds: 120); + return Ok(new LazyLoginCaptchaResponse( + captchaId, + $"data:image/gif;base64,{Convert.ToBase64String(generated.Bytes)}")); } [AllowAnonymous] @@ -172,11 +156,7 @@ public sealed class AuthController( LoginRequest request, CancellationToken cancellationToken) { - if (!await loginCaptcha.ConsumeProofAsync( - request.CaptchaTicket, - request.DeviceId, - HttpContext, - cancellationToken)) + if (!captcha.Validate(request.CaptchaId, request.CaptchaCode)) return Unauthorized(LoginCaptchaProblem()); var user = await userManager.FindByNameAsync(request.UserName); if (user is null || !user.IsEnabled) @@ -350,20 +330,11 @@ 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, + [Required, MinLength(16), MaxLength(64)] string CaptchaId, + [Required, MinLength(3), MaxLength(16)] string CaptchaCode, 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 LazyLoginCaptchaResponse(string CaptchaId, string ImageData); 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 d3a8c34..c0f2bd0 100644 --- a/src/Jiaowu.Api/Controllers/SsoController.cs +++ b/src/Jiaowu.Api/Controllers/SsoController.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; +using Lazy.Captcha.Core; using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace Jiaowu.Api.Controllers; @@ -22,7 +23,7 @@ public sealed class SsoController( UserManager userManager, IAuthSessionService authSessionService, IDistributedCache cache, - ILoginCaptchaService loginCaptcha, + ICaptcha captcha, IOptions options, ILogger logger) : ControllerBase { @@ -47,8 +48,8 @@ public sealed class SsoController( [FromQuery] string? bindingIntent = null, [FromQuery] bool nativeApp = false, [FromQuery] string? nativeState = null, - [FromQuery] string? captchaTicket = null, - [FromQuery] string? deviceId = null, + [FromQuery] string? captchaId = null, + [FromQuery] string? captchaCode = null, CancellationToken cancellationToken = default) { if (!_options.Enabled) @@ -56,8 +57,9 @@ public sealed class SsoController( var safeReturnUrl = NormalizeReturnUrl(returnUrl); if (string.IsNullOrWhiteSpace(bindingIntent) && - (string.IsNullOrWhiteSpace(captchaTicket) || - !await loginCaptcha.ConsumeProofAsync(captchaTicket, deviceId, HttpContext, cancellationToken))) + (string.IsNullOrWhiteSpace(captchaId) || + string.IsNullOrWhiteSpace(captchaCode) || + !captcha.Validate(captchaId, captchaCode))) { return Unauthorized(AuthController.LoginCaptchaProblem()); } @@ -417,8 +419,8 @@ public sealed class SsoController( bindingIntent = intentCode, nativeApp, nativeState, - captchaTicket = (string?)null, - deviceId = (string?)null + captchaId = (string?)null, + captchaCode = (string?)null })!; return new SsoBindingStartResponse(loginUrl); } diff --git a/src/Jiaowu.Api/Infrastructure/Auth/LoginCaptchaService.cs b/src/Jiaowu.Api/Infrastructure/Auth/LoginCaptchaService.cs deleted file mode 100644 index 4d11dc9..0000000 --- a/src/Jiaowu.Api/Infrastructure/Auth/LoginCaptchaService.cs +++ /dev/null @@ -1,160 +0,0 @@ -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/Jiaowu.Api.csproj b/src/Jiaowu.Api/Jiaowu.Api.csproj index e316225..6692e71 100644 --- a/src/Jiaowu.Api/Jiaowu.Api.csproj +++ b/src/Jiaowu.Api/Jiaowu.Api.csproj @@ -16,27 +16,19 @@ - - + + - + + @@ -56,22 +48,10 @@ - - - - + + + + diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index c0077ff..1a67ab7 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -19,6 +19,7 @@ using Jiaowu.Api.Infrastructure.Operations; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Timetables; +using Lazy.Captcha.Core; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.Cookies; @@ -392,6 +393,7 @@ builder.Services.AddHybridCache(options => options.MaximumKeyLength = 512; options.MaximumPayloadBytes = cacheOptions.MaximumPayloadKilobytes * 1024; }); +builder.Services.AddCaptcha(builder.Configuration); builder.Services.AddSingleton(); builder.Services @@ -436,7 +438,6 @@ 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/ArchiveBatchRequestValidationTests.cs b/tests/Jiaowu.Api.Tests/ArchiveBatchRequestValidationTests.cs new file mode 100644 index 0000000..4e370f4 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/ArchiveBatchRequestValidationTests.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Jiaowu.Api.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Jiaowu.Api.Tests; + +public sealed class ArchiveBatchRequestValidationTests +{ + [Fact] + public void Mvc_validator_accepts_archive_record_metadata() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddControllers().AddApplicationPart(typeof(ArchivesController).Assembly); + using var provider = services.BuildServiceProvider(); + var actionContext = new ActionContext( + new DefaultHttpContext { RequestServices = provider }, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + + var exception = Record.Exception(() => provider.GetRequiredService() + .Validate(actionContext, null, string.Empty, + new ArchiveBatchRequest([Guid.NewGuid()], true, "学期已结束"))); + + Assert.Null(exception); + Assert.True(actionContext.ModelState.IsValid); + } + + [Fact] + public void Validation_metadata_is_attached_to_record_constructor_parameters() + { + var constructor = Assert.Single(typeof(ArchiveBatchRequest).GetConstructors()); + var parameters = constructor.GetParameters(); + + Assert.Contains(parameters[0].GetCustomAttributes(), x => x is RequiredAttribute); + Assert.Contains(parameters[0].GetCustomAttributes(), x => x is MinLengthAttribute { Length: 1 }); + Assert.Contains(parameters[0].GetCustomAttributes(), x => x is MaxLengthAttribute { Length: 100 }); + Assert.Contains(parameters[2].GetCustomAttributes(), x => x is MaxLengthAttribute { Length: 500 }); + + Assert.Empty(typeof(ArchiveBatchRequest).GetProperty(nameof(ArchiveBatchRequest.Ids))! + .GetCustomAttributes()); + Assert.Empty(typeof(ArchiveBatchRequest).GetProperty(nameof(ArchiveBatchRequest.Reason))! + .GetCustomAttributes()); + } +} diff --git a/tests/Jiaowu.Api.Tests/AuthControllerTests.cs b/tests/Jiaowu.Api.Tests/AuthControllerTests.cs index 5b069b7..ffa8c36 100644 --- a/tests/Jiaowu.Api.Tests/AuthControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/AuthControllerTests.cs @@ -10,6 +10,8 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Lazy.Captcha.Core; namespace Jiaowu.Api.Tests; @@ -47,6 +49,8 @@ public sealed class AuthControllerTests await connection.OpenAsync(); var services = new ServiceCollection(); services.AddLogging(); + services.AddDistributedMemoryCache(); + services.AddCaptcha(new ConfigurationBuilder().Build()); services.AddDbContext(options => options .UseSqlite(connection) .ReplaceService()); @@ -109,8 +113,7 @@ 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()))), + scope.ServiceProvider.GetRequiredService(), NoOpAppCache.Instance); var request = new StudentActivationRequest( student.Name, diff --git a/tests/Jiaowu.Api.Tests/LazyCaptchaIntegrationTests.cs b/tests/Jiaowu.Api.Tests/LazyCaptchaIntegrationTests.cs new file mode 100644 index 0000000..2b029ec --- /dev/null +++ b/tests/Jiaowu.Api.Tests/LazyCaptchaIntegrationTests.cs @@ -0,0 +1,26 @@ +using Lazy.Captcha.Core; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Jiaowu.Api.Tests; + +public sealed class LazyCaptchaIntegrationTests +{ + [Fact] + public void Generated_code_is_valid_once_and_returns_image_bytes() + { + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddCaptcha(new ConfigurationBuilder().Build()); + using var provider = services.BuildServiceProvider(); + var captcha = provider.GetRequiredService(); + var id = Guid.NewGuid().ToString("N"); + + var generated = captcha.Generate(id, expirySeconds: 120); + + Assert.NotEmpty(generated.Bytes); + Assert.True(captcha.Validate(id, generated.Code)); + Assert.False(captcha.Validate(id, generated.Code)); + } +} diff --git a/tests/Jiaowu.Api.Tests/LoginCaptchaServiceTests.cs b/tests/Jiaowu.Api.Tests/LoginCaptchaServiceTests.cs deleted file mode 100644 index e7bfaaf..0000000 --- a/tests/Jiaowu.Api.Tests/LoginCaptchaServiceTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -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 c624440..bc521af 100644 --- a/tests/Jiaowu.Api.Tests/SsoControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/SsoControllerTests.cs @@ -13,6 +13,8 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Configuration; +using Lazy.Captcha.Core; namespace Jiaowu.Api.Tests; @@ -163,6 +165,7 @@ public sealed class SsoControllerTests var services = new ServiceCollection(); services.AddLogging(); services.AddDistributedMemoryCache(); + services.AddCaptcha(new ConfigurationBuilder().Build()); services.AddDbContext(options => options.UseSqlite(connection)); services .AddIdentityCore() @@ -187,7 +190,7 @@ public sealed class SsoControllerTests userManager, new StubAuthSessionService(), cache, - new LoginCaptchaService(cache), + provider.GetRequiredService(), Options.Create(new SsoOptions { Enabled = true, diff --git a/web/package-lock.json b/web/package-lock.json index 671af2d..05a6a2f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,7 +17,6 @@ "@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", @@ -2675,12 +2674,6 @@ "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 5739823..f8d82cb 100644 --- a/web/package.json +++ b/web/package.json @@ -26,7 +26,6 @@ "@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/auth/deviceFingerprint.ts b/web/src/auth/deviceFingerprint.ts deleted file mode 100644 index 49fa37d..0000000 --- a/web/src/auth/deviceFingerprint.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/LoginCaptcha.vue b/web/src/components/LoginCaptcha.vue index ea1ff6c..8a2b518 100644 --- a/web/src/components/LoginCaptcha.vue +++ b/web/src/components/LoginCaptcha.vue @@ -1,106 +1,43 @@ @@ -108,14 +45,7 @@ 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-prompt { margin: 0; color: #5c7084; font-size: 13px; } -.captcha-stage { min-height: 132px; display: grid; gap: 8px; } -.captcha-image { position: relative; width: min(100%, 280px); line-height: 0; overflow: hidden; border-radius: 9px; } -.captcha-image.clickable { cursor: crosshair; } -.captcha-image img { width: 100%; user-select: none; } -.captcha-image i { position: absolute; transform: translate(-50%, -50%); display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: #1769aa; color: white; font-size: 12px; font-style: normal; line-height: 1; } -.slider-control { position: relative; width: min(100%, 280px); height: 35px; display: grid; place-items: center; background: #edf3f8; border-radius: 7px; } -.slider-control input { width: calc(100% - 18px); accent-color: #347ab1; } -.slider-piece { position: absolute; top: -29px; color: white; text-shadow: 0 1px 3px #1d3a55; pointer-events: none; } -small { color: #63768b; } +.captcha-input { 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; } +@media (max-width: 420px) { .captcha-input { grid-template-columns: 130px minmax(0, 1fr); } .captcha-input img { width: 130px; } } diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 04cbe82..c1db298 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -31,14 +31,14 @@ export const useAuthStore = defineStore('auth', () => { async function login( userName: string, password: string, - captchaTicket: string, - deviceId: string, + captchaId: string, + captchaCode: string, ): Promise<{ twoFactorTicket?: string }> { const { data } = await http.post('/auth/login', { userName, password, - captchaTicket, - deviceId, + captchaId, + captchaCode, 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 0c26c25..a9312b1 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -13,8 +13,8 @@ const router = useRouter() const auth = useAuthStore() const loading = ref(false) const twoFactorTicket = ref('') -const captchaTicket = ref('') -const captchaDeviceId = ref('') +const captchaId = ref('') +const captchaCode = ref('') const captcha = ref<{ refresh: () => Promise } | null>(null) const ssoLoading = ref(false) const sso = reactive({ enabled: false, displayName: '学校统一身份认证' }) @@ -27,14 +27,14 @@ const form = reactive({ async function submit() { loading.value = true try { - const result = await auth.login(form.userName, form.password, captchaTicket.value, captchaDeviceId.value) + const result = await auth.login(form.userName, form.password, captchaId.value, captchaCode.value) if (result.twoFactorTicket) { twoFactorTicket.value = result.twoFactorTicket return } await router.replace(String(route.query.redirect ?? '/dashboard')) } catch (error) { - captchaTicket.value = '' + captchaCode.value = '' await captcha.value?.refresh() ElMessage.error(apiErrorMessage(error)) } finally { @@ -60,9 +60,9 @@ async function startSso() { const redirect = String(route.query.redirect ?? '/dashboard') const nativeState = beginNativeSsoLogin() try { - if (!captchaTicket.value) return + if (!captchaId.value || !captchaCode.value) return await openSsoLogin( - `${apiBaseUrl}/auth/sso/login?returnUrl=${encodeURIComponent(redirect)}&nativeApp=${isNativeApp()}&captchaTicket=${encodeURIComponent(captchaTicket.value)}&deviceId=${encodeURIComponent(captchaDeviceId.value)}${nativeState ? `&nativeState=${encodeURIComponent(nativeState)}` : ''}`, + `${apiBaseUrl}/auth/sso/login?returnUrl=${encodeURIComponent(redirect)}&nativeApp=${isNativeApp()}&captchaId=${encodeURIComponent(captchaId.value)}&captchaCode=${encodeURIComponent(captchaCode.value)}${nativeState ? `&nativeState=${encodeURIComponent(nativeState)}` : ''}`, ) } catch (error) { ssoLoading.value = false @@ -141,8 +141,7 @@ onMounted(async () => { @@ -160,7 +159,7 @@ onMounted(async () => { class="sso-login-submit" size="large" :loading="ssoLoading" - :disabled="!captchaTicket" + :disabled="!captchaId || !captchaCode" @click="startSso" > 使用{{ sso.displayName }}登录