diff --git a/backend/AuthService/InMemoryRepositories.cs b/backend/AuthService/InMemoryRepositories.cs index 3bbaff0..d3365cf 100644 --- a/backend/AuthService/InMemoryRepositories.cs +++ b/backend/AuthService/InMemoryRepositories.cs @@ -196,21 +196,22 @@ public string CreatePasswordReset(string userId) lock (store.Sync) { foreach (var code in store.PasswordResets.Where(pair => pair.Value.UserId == userId).Select(pair => pair.Key).ToArray()) store.PasswordResets.Remove(code); - store.PasswordResets["123456"] = new MockPasswordReset(userId, DateTimeOffset.UtcNow.AddMinutes(10)); - return "123456"; + var token = "ABCD1234"; + store.PasswordResets[token.ToUpperInvariant()] = new MockPasswordReset(userId, DateTimeOffset.UtcNow.AddMinutes(10)); + return token; } } public void DeletePasswordReset(string token) { - lock (store.Sync) store.PasswordResets.Remove(token); + lock (store.Sync) store.PasswordResets.Remove(token.ToUpperInvariant()); } public Credential? ConsumePasswordReset(string token) { lock (store.Sync) { - if (!store.PasswordResets.Remove(token, out var reset) || reset.ExpiresAt <= DateTimeOffset.UtcNow) return null; + if (!store.PasswordResets.Remove(token.ToUpperInvariant(), out var reset) || reset.ExpiresAt <= DateTimeOffset.UtcNow) return null; return store.CredentialsById.GetValueOrDefault(reset.UserId); } } diff --git a/backend/AuthService/Program.cs b/backend/AuthService/Program.cs index 7927e8e..be72404 100644 --- a/backend/AuthService/Program.cs +++ b/backend/AuthService/Program.cs @@ -20,7 +20,10 @@ var gatewayBaseUrl = builder.Configuration["Gateway:BaseUrl"] ?? "http://localhost:5000"; var gatewayUri = new Uri(gatewayBaseUrl, UriKind.Absolute); var adminUsername = builder.Configuration["Admin:Username"] ?? throw new InvalidOperationException("Admin:Username must be configured."); -var adminPassword = builder.Configuration["Admin:Password"] ?? throw new InvalidOperationException("Admin:Password must be configured."); +var adminPasswordHash = builder.Configuration["Admin:PasswordHash"]; +var adminPasswordLegacy = builder.Configuration["Admin:Password"]; +if (string.IsNullOrWhiteSpace(adminPasswordHash) && string.IsNullOrWhiteSpace(adminPasswordLegacy)) + throw new InvalidOperationException("Admin:PasswordHash (preferred) or Admin:Password (legacy plaintext) must be configured."); var isDevelopment = builder.Environment.IsDevelopment(); var storageName = isMockMode ? "memory" : "mysql"; builder.Services.AddSingleton(); @@ -157,10 +160,31 @@ await context.HttpContext.Response.WriteAsJsonAsync( var session = repository.CreateSession(credential.UserId, request.DeviceName); return Results.Created($"/api/v1/auth/sessions/{session.SessionId}", ApiSuccess.Create(session.ToResponse(), c.TraceIdentifier)); }); -app.MapPost("/api/v1/admin/sessions", (AdminLoginRequest request, HttpContext c, IAuthRepository repository) => +app.MapPost("/api/v1/admin/sessions", (AdminLoginRequest request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher) => { - if (!FixedTimeEquals(request.Username?.Trim() ?? string.Empty, adminUsername) || - !FixedTimeEquals(request.Password ?? string.Empty, adminPassword)) + if (!FixedTimeEquals(request.Username?.Trim() ?? string.Empty, adminUsername)) + return Failure(c, 401, "AUTH_REQUIRED", "管理员账号或密码错误"); + + // Prefer hashed password (Admin:PasswordHash). Fall back to legacy plaintext + // (Admin:Password) only if the hash is not configured, using FixedTimeEquals. + var password = request.Password ?? string.Empty; + var passwordValid = false; + if (!string.IsNullOrWhiteSpace(adminPasswordHash)) + { + var adminCredential = new Credential(AdminIdentity.UserId, "admin", adminPasswordHash); + try + { + passwordValid = hasher.VerifyHashedPassword(adminCredential, adminPasswordHash, password) is not PasswordVerificationResult.Failed; + } + catch (FormatException) { passwordValid = false; } + catch (ArgumentException) { passwordValid = false; } + } + else + { + passwordValid = FixedTimeEquals(password, adminPasswordLegacy!); + } + + if (!passwordValid) return Failure(c, 401, "AUTH_REQUIRED", "管理员账号或密码错误"); var session = repository.CreateSession(AdminIdentity.UserId, "MoonStone admin"); return Results.Created($"/api/v1/auth/sessions/{session.SessionId}", ApiSuccess.Create(session.ToResponse(), c.TraceIdentifier)); @@ -269,10 +293,14 @@ await context.HttpContext.Response.WriteAsJsonAsync( { if (!ValidEmail(request.Email)) return Failure(c, 400, "VALIDATION_ERROR", "请输入有效的邮箱地址"); var credential = repository.FindCredential(request.Email.Trim().ToLowerInvariant()); - if (credential is null) return Failure(c, 404, "RESOURCE_NOT_FOUND", "该邮箱未注册"); - var resetToken = repository.CreatePasswordReset(credential.UserId); - var delivered = await emailSender.SendAsync(credential.Email, resetToken, c.TraceIdentifier, c.RequestAborted); - if (!delivered) repository.DeletePasswordReset(resetToken); + // Always return 202 Accepted to prevent email enumeration. + // If the email is registered, a reset token is sent; otherwise, no action is taken. + if (credential is not null) + { + var resetToken = repository.CreatePasswordReset(credential.UserId); + var delivered = await emailSender.SendAsync(credential.Email, resetToken, c.TraceIdentifier, c.RequestAborted); + if (!delivered) repository.DeletePasswordReset(resetToken); + } return Results.Accepted(); }); app.MapPost("/api/v1/auth/password-resets", (PasswordResetConfirmation request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher) => @@ -528,6 +556,7 @@ public bool DeleteAccount(string userId) public string CreatePasswordReset(string userId) { var token = PasswordResetToken(); + var normalizedToken = token.ToUpperInvariant(); using var c = database.OpenConnection(); using var tx = c.BeginTransaction(); using var q = c.CreateCommand(); @@ -537,18 +566,29 @@ public string CreatePasswordReset(string userId) q.ExecuteNonQuery(); q.Parameters.Clear(); q.CommandText = "INSERT INTO auth_password_resets (token_hash,user_id,expires_at,used_at) VALUES (@hash,@user,@expires,NULL);"; - q.Parameters.AddWithValue("@hash", Hash(token)); + q.Parameters.AddWithValue("@hash", Hash(normalizedToken)); q.Parameters.AddWithValue("@user", userId); q.Parameters.AddWithValue("@expires", DateTime.UtcNow.AddMinutes(10)); q.ExecuteNonQuery(); tx.Commit(); return token; } - public void DeletePasswordReset(string token){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="DELETE FROM auth_password_resets WHERE token_hash=@hash AND used_at IS NULL;";q.Parameters.AddWithValue("@hash",Hash(token));q.ExecuteNonQuery();} - public Credential? ConsumePasswordReset(string token){if(token.Length!=6||token.Any(character=>!char.IsAsciiDigit(character)))return null;using var c=database.OpenConnection();using var tx=c.BeginTransaction();using var q=c.CreateCommand();q.Transaction=tx;q.CommandText="SELECT CAST(user_id AS CHAR) FROM auth_password_resets WHERE token_hash=@hash AND used_at IS NULL AND expires_at>UTC_TIMESTAMP(6) LIMIT 1 FOR UPDATE;";q.Parameters.AddWithValue("@hash",Hash(token));var rawUser=q.ExecuteScalar();var user=rawUser is null?null:DbText(rawUser);if(user is null){tx.Rollback();return null;}q.Parameters.Clear();q.CommandText="UPDATE auth_password_resets SET used_at=UTC_TIMESTAMP(6) WHERE token_hash=@hash;";q.Parameters.AddWithValue("@hash",Hash(token));q.ExecuteNonQuery();tx.Commit();using var q2=c.CreateCommand();q2.CommandText="SELECT CAST(user_id AS CHAR),email,password_hash FROM auth_credentials WHERE user_id=@id;";q2.Parameters.AddWithValue("@id",user);using var r=q2.ExecuteReader();return r.Read()?new Credential(DbText(r.GetValue(0)),r.GetString(1),r.GetString(2)):null;} + public void DeletePasswordReset(string token){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="DELETE FROM auth_password_resets WHERE token_hash=@hash AND used_at IS NULL;";q.Parameters.AddWithValue("@hash",Hash(token.ToUpperInvariant()));q.ExecuteNonQuery();} + public Credential? ConsumePasswordReset(string token){if(string.IsNullOrWhiteSpace(token)||token.Length<6||token.Length>32)return null;using var c=database.OpenConnection();using var tx=c.BeginTransaction();using var q=c.CreateCommand();q.Transaction=tx;q.CommandText="SELECT CAST(user_id AS CHAR) FROM auth_password_resets WHERE token_hash=@hash AND used_at IS NULL AND expires_at>UTC_TIMESTAMP(6) LIMIT 1 FOR UPDATE;";q.Parameters.AddWithValue("@hash",Hash(token.ToUpperInvariant()));var rawUser=q.ExecuteScalar();var user=rawUser is null?null:DbText(rawUser);if(user is null){tx.Rollback();return null;}q.Parameters.Clear();q.CommandText="UPDATE auth_password_resets SET used_at=UTC_TIMESTAMP(6) WHERE token_hash=@hash;";q.Parameters.AddWithValue("@hash",Hash(token.ToUpperInvariant()));q.ExecuteNonQuery();tx.Commit();using var q2=c.CreateCommand();q2.CommandText="SELECT CAST(user_id AS CHAR),email,password_hash FROM auth_credentials WHERE user_id=@id;";q2.Parameters.AddWithValue("@id",user);using var r=q2.ExecuteReader();return r.Read()?new Credential(DbText(r.GetValue(0)),r.GetString(1),r.GetString(2)):null;} private void InsertSession(StoredSession s){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="INSERT INTO auth_sessions (session_id,user_id,access_hash,refresh_hash,created_at,access_expires_at,refresh_expires_at,revoked_at) VALUES (@id,@user,@access,@refresh,@created,@accessExpires,@refreshExpires,NULL);";q.Parameters.AddWithValue("@id",s.SessionId);q.Parameters.AddWithValue("@user",s.UserId);q.Parameters.AddWithValue("@access",Hash(s.AccessToken));q.Parameters.AddWithValue("@refresh",Hash(s.RefreshToken));q.Parameters.AddWithValue("@created",s.CreatedAt.UtcDateTime);q.Parameters.AddWithValue("@accessExpires",s.AccessExpiresAt.UtcDateTime);q.Parameters.AddWithValue("@refreshExpires",s.RefreshExpiresAt.UtcDateTime);q.ExecuteNonQuery();} private StoredSession? FindSession(string column,string value,bool hashed){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText=$"SELECT CAST(session_id AS CHAR),CAST(user_id AS CHAR),created_at,access_expires_at,refresh_expires_at,revoked_at FROM auth_sessions WHERE {column}=@value LIMIT 1;";q.Parameters.AddWithValue("@value",value);using var r=q.ExecuteReader();return r.Read()?new StoredSession(DbText(r.GetValue(0)),DbText(r.GetValue(1)),string.Empty,string.Empty,AsUtc(r.GetDateTime(2)),AsUtc(r.GetDateTime(3)),AsUtc(r.GetDateTime(4)),r.IsDBNull(5)?null:AsUtc(r.GetDateTime(5))):null;} - private static string Token()=>Convert.ToBase64String(RandomNumberGenerator.GetBytes(48)).Replace('+','-').Replace('/','_').TrimEnd('='); private static string PasswordResetToken()=>RandomNumberGenerator.GetInt32(1_000_000).ToString("D6"); private static string Hash(string value)=>Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value)));private static string DbText(object value)=>value is Guid guid?guid.ToString():Convert.ToString(value)!;private static DateTimeOffset AsUtc(DateTime value)=>new(DateTime.SpecifyKind(value,DateTimeKind.Utc)); + private static string Token()=>Convert.ToBase64String(RandomNumberGenerator.GetBytes(48)).Replace('+','-').Replace('/','_').TrimEnd('='); private static string PasswordResetToken()=>GenerateResetToken(); private static string Hash(string value)=>Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value)));private static string DbText(object value)=>value is Guid guid?guid.ToString():Convert.ToString(value)!;private static DateTimeOffset AsUtc(DateTime value)=>new(DateTime.SpecifyKind(value,DateTimeKind.Utc)); + private const string ResetTokenAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private static string GenerateResetToken() + { + // 8 characters from a 30-symbol alphabet ≈ 39 bits of entropy + // (vs. ~20 bits for a 6-digit numeric code). + var bytes = RandomNumberGenerator.GetBytes(8); + var chars = new char[8]; + for (var i = 0; i < 8; i++) + chars[i] = ResetTokenAlphabet[bytes[i] % ResetTokenAlphabet.Length]; + return new string(chars); + } } public sealed class MySqlAdminRepository(AuthDatabase database) : IAdminRepository diff --git a/backend/AuthService/appsettings.Development.json b/backend/AuthService/appsettings.Development.json index dc168cb..e42ede9 100644 --- a/backend/AuthService/appsettings.Development.json +++ b/backend/AuthService/appsettings.Development.json @@ -1,7 +1,7 @@ { "Admin": { "Username": "admin", - "Password": "admin" + "PasswordHash": "AAGghgEASpusiuNP6A1/TqRUMmxU8IIKqAD8UeYF3LaiojJYTSps3BvJxJHathAQNV/7Pqpy" }, "ConnectionStrings": { "AuthDatabase": "Server=localhost;Port=3306;Database=moonstone_auth;User ID=root;Password=root;SslMode=None;AllowPublicKeyRetrieval=True;" diff --git a/backend/FileService/InternalServiceAccessPolicy.cs b/backend/FileService/InternalServiceAccessPolicy.cs index ec530af..fccc06f 100644 --- a/backend/FileService/InternalServiceAccessPolicy.cs +++ b/backend/FileService/InternalServiceAccessPolicy.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Primitives; +using System.Security.Cryptography; public static class InternalServiceAccessPolicy { @@ -22,7 +23,7 @@ public static bool IsTrusted( string gatewayKey, IReadOnlySet? allowedServices = null) { - if (!HasSingleExactValue(headers, "X-Gateway-Key", gatewayKey, StringComparison.Ordinal)) + if (!HasSingleExactValue(headers, "X-Gateway-Key", gatewayKey)) { return false; } @@ -44,12 +45,20 @@ public static bool IsTrusted( private static bool HasSingleExactValue( IHeaderDictionary headers, string headerName, - string expected, - StringComparison comparison) + string expected) { - return headers.TryGetValue(headerName, out StringValues values) - && values.Count == 1 - && string.Equals(values[0], expected, comparison); + if (!headers.TryGetValue(headerName, out StringValues values) || values.Count != 1) + return false; + + var expectedBytes = System.Text.Encoding.UTF8.GetBytes(expected); + var actualBytes = System.Text.Encoding.UTF8.GetBytes(values[0]!); + var length = Math.Max(expectedBytes.Length, actualBytes.Length); + var paddedExpected = new byte[length]; + var paddedActual = new byte[length]; + expectedBytes.CopyTo(paddedExpected, 0); + actualBytes.CopyTo(paddedActual, 0); + return CryptographicOperations.FixedTimeEquals(paddedExpected, paddedActual) + && expectedBytes.Length == actualBytes.Length; } private static IEnumerable SplitScalar(string? value) diff --git a/backend/FileService/Program.cs b/backend/FileService/Program.cs index dbab3ee..1d05045 100644 --- a/backend/FileService/Program.cs +++ b/backend/FileService/Program.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; +using System.Security.Cryptography; var builder = WebApplication.CreateBuilder(args); builder.Services.Configure( @@ -16,10 +17,12 @@ var ocrBaseUri = new Uri( builder.Configuration["Ocr:BaseUrl"] ?? "http://127.0.0.1:5110/", UriKind.Absolute); +var ocrGatewayKey = builder.Configuration["Ocr:GatewayKey"] ?? gatewayKey; builder.Services.AddHttpClient("ocr", client => { client.BaseAddress = ocrBaseUri; client.Timeout = TimeSpan.FromMinutes(builder.Configuration.GetValue("Ocr:TimeoutMinutes") ?? 20); + client.DefaultRequestHeaders.Add("X-Gateway-Key", ocrGatewayKey); }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { UseProxy = false }); builder.Services.AddSingleton(); builder.Services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); @@ -180,7 +183,22 @@ }); app.Run(); -static string? GatewayUser(HttpContext context, string key) => context.Request.Headers["X-Gateway-Key"] == key && Guid.TryParse(context.Request.Headers["X-User-Id"], out _) ? context.Request.Headers["X-User-Id"].ToString() : null; +static string? GatewayUser(HttpContext context, string key) +{ + var gatewayKeyHeader = context.Request.Headers["X-Gateway-Key"]; + if (gatewayKeyHeader.Count != 1) return null; + var keyBytes = System.Text.Encoding.UTF8.GetBytes(key); + var headerBytes = System.Text.Encoding.UTF8.GetBytes(gatewayKeyHeader[0]!); + var length = Math.Max(keyBytes.Length, headerBytes.Length); + var paddedKey = new byte[length]; + var paddedHeader = new byte[length]; + keyBytes.CopyTo(paddedKey, 0); + headerBytes.CopyTo(paddedHeader, 0); + if (!CryptographicOperations.FixedTimeEquals(paddedKey, paddedHeader) || keyBytes.Length != headerBytes.Length) + return null; + var userId = context.Request.Headers["X-User-Id"]; + return Guid.TryParse(userId, out _) ? userId.ToString() : null; +} static string? NormalizeSubjectCode(string? value) { if (value is null) return null; diff --git a/backend/GalGameService/MongoGameStore.cs b/backend/GalGameService/MongoGameStore.cs index 9b5c469..5f445d0 100644 --- a/backend/GalGameService/MongoGameStore.cs +++ b/backend/GalGameService/MongoGameStore.cs @@ -117,6 +117,7 @@ public sealed class MongoGameStore : IGameStore private readonly IMongoCollection _owners; private readonly IMongoCollection _audio; private readonly IMongoDatabase _database; + private readonly IMongoClient _client; private readonly ILogger? _logger; // JSON 序列化选项(与 Program.cs 的 HttpJsonOptions 一致) @@ -139,7 +140,8 @@ public MongoGameStore( var databaseName = configuration["MongoDb:Database"] ?? "moonstone_galgame"; - _database = new MongoClient(connectionString).GetDatabase(databaseName); + _client = new MongoClient(connectionString); + _database = _client.GetDatabase(databaseName); _jobs = _database.GetCollection("game_jobs"); _packages = _database.GetCollection("game_packages"); _manifests = _database.GetCollection("game_manifests"); @@ -394,21 +396,34 @@ public void SavePackage(GamePackage package, GamePackageManifest manifest, strin var packageJson = JsonSerializer.Serialize(package, JsonOpts); var packageBson = BsonDocument.Parse(packageJson); - // 原子写入三个集合(MongoDB 单文档操作各自原子) - _packages.ReplaceOne( - new BsonDocument("_id", new BsonBinaryData(package.PackageId, GuidRepresentation.Standard)), - packageBson, - new ReplaceOptions { IsUpsert = true }); - - _manifests.ReplaceOne( - m => m.PackageId == package.PackageId, - manifest, - new ReplaceOptions { IsUpsert = true }); + var packageIdFilter = new BsonDocument("_id", new BsonBinaryData(package.PackageId, GuidRepresentation.Standard)); + var manifestFilter = Builders.Filter.Eq(m => m.PackageId, package.PackageId); + var ownerFilter = Builders.Filter.Eq(o => o.PackageId, package.PackageId); + var ownerDoc = new PackageOwnerDocument { PackageId = package.PackageId, OwnerUserId = ownerUserId }; - _owners.ReplaceOne( - o => o.PackageId == package.PackageId, - new PackageOwnerDocument { PackageId = package.PackageId, OwnerUserId = ownerUserId }, - new ReplaceOptions { IsUpsert = true }); + // Use a transaction to ensure all three collections are written atomically. + // If transactions are not supported (e.g. standalone MongoDB without replica set), + // fall back to sequential writes with best-effort cleanup on failure. + try + { + using var session = _client.StartSession(); + session.WithTransaction((s, ct) => + { + _packages.ReplaceOne(s, packageIdFilter, packageBson, new ReplaceOptions { IsUpsert = true }); + _manifests.ReplaceOne(s, manifestFilter, manifest, new ReplaceOptions { IsUpsert = true }); + _owners.ReplaceOne(s, ownerFilter, ownerDoc, new ReplaceOptions { IsUpsert = true }); + return true; + }); + } + catch (MongoCommandException ex) when (ex.ErrorMessage.Contains("Transaction", StringComparison.OrdinalIgnoreCase) || + ex.ErrorMessage.Contains("replica set", StringComparison.OrdinalIgnoreCase)) + { + // Fallback for standalone MongoDB without replica set support. + _logger?.LogWarning("MongoDB transactions not available, falling back to sequential writes for SavePackage."); + _packages.ReplaceOne(packageIdFilter, packageBson, new ReplaceOptions { IsUpsert = true }); + _manifests.ReplaceOne(manifestFilter, manifest, new ReplaceOptions { IsUpsert = true }); + _owners.ReplaceOne(ownerFilter, ownerDoc, new ReplaceOptions { IsUpsert = true }); + } } public GamePackage? GetPackage(Guid packageId) diff --git a/backend/GalGameService/Program.cs b/backend/GalGameService/Program.cs index 0fa3aca..0c3d5b4 100644 --- a/backend/GalGameService/Program.cs +++ b/backend/GalGameService/Program.cs @@ -482,7 +482,7 @@ void ReportProgress(int progress) catch (Exception releaseError) { logger.LogError(releaseError, "Unable to release credits for failed job {GenerationId}", job.GenerationId); } } store.TryTransitionJob(job.GenerationId, JobStatus.RUNNING, - j => j with { Status = JobStatus.FAILED, Error = new ApiError("INTERNAL_ERROR", ex.Message, new Dictionary()) }); + j => j with { Status = JobStatus.FAILED, Error = new ApiError("INTERNAL_ERROR", "生成任务处理失败,请稍后重试或联系支持团队", new Dictionary()) }); } }); diff --git a/backend/OCRService/app.py b/backend/OCRService/app.py index 69f73db..4f0168c 100644 --- a/backend/OCRService/app.py +++ b/backend/OCRService/app.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hmac import json import os import re @@ -18,17 +19,40 @@ os.environ.setdefault("PADDLE_PDX_MODEL_SOURCE", "BOS") import fitz -from fastapi import FastAPI, File, Header, HTTPException, UploadFile +from fastapi import FastAPI, File, Header, HTTPException, Request, UploadFile from paddleocr import FormulaRecognitionPipeline, PaddleOCR app = FastAPI(title="MoonStone Local OCR", version="1.0") MAX_UPLOAD_BYTES = 10 * 1024 * 1024 +MAX_PDF_PAGES = 50 IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"} JOB_PROGRESS: dict[str, dict[str, object]] = {} # Paddle/oneDNN inference is not safe to run concurrently in one Python process. OCR_INFERENCE_LOCK = Lock() CANCELLED_JOBS: set[str] = set() +# Gateway key for authenticating requests from the API Gateway / FileService. +# Must match the Gateway__ServiceKey configured on the calling service. +GATEWAY_KEY = os.environ.get("GATEWAY_KEY", "") + + +def _verify_gateway_key(provided: str | None) -> bool: + """Constant-time comparison of the gateway key.""" + if not GATEWAY_KEY or not provided: + return False + return hmac.compare_digest(GATEWAY_KEY, provided) + + +@app.middleware("http") +async def authenticate_gateway_requests(request: Request, call_next): + """Require X-Gateway-Key on all endpoints except /healthz.""" + if request.url.path == "/healthz": + return await call_next(request) + gateway_key = request.headers.get("X-Gateway-Key") + if not _verify_gateway_key(gateway_key): + raise HTTPException(401, "A valid X-Gateway-Key header is required.") + return await call_next(request) + def ensure_not_cancelled(job_id: str | None) -> None: if job_id and job_id in CANCELLED_JOBS: @@ -228,6 +252,8 @@ def merge_ocr_regions(text_regions: list[TextRegion], formula_regions: list[Form def render_pdf(pdf_path: Path, output_dir: Path, mode: str) -> list[Path]: document = fitz.open(pdf_path) try: + if document.page_count > MAX_PDF_PAGES: + raise HTTPException(413, f"PDF exceeds the {MAX_PDF_PAGES}-page limit.") pages: list[Path] = [] for number, page in enumerate(document, start=1): image_path = output_dir / f"page-{number}.png" diff --git a/compose.integration.yaml b/compose.integration.yaml index 288ea9c..65853e3 100644 --- a/compose.integration.yaml +++ b/compose.integration.yaml @@ -119,7 +119,7 @@ services: Gateway__BaseUrl: http://gateway:5000 Gateway__ServiceKey: ${AUTH_SERVICE_KEY:-moonstone-local-gateway-key} Admin__Username: ${GALREVIEW_ADMIN_USERNAME:-integration-admin} - Admin__Password: ${GALREVIEW_ADMIN_PASSWORD:-integration-admin-password} + Admin__PasswordHash: ${GALREVIEW_ADMIN_PASSWORD_HASH:-} Email__SmtpHost: ${SMTP_HOST:-} Email__SmtpPort: ${SMTP_PORT:-465} Email__UseSsl: ${SMTP_USE_SSL:-true} @@ -331,6 +331,8 @@ services: - ocr build: context: ./backend/OCRService + environment: + GATEWAY_KEY: ${FILE_SERVICE_KEY:-moonstone-local-gateway-key} restart: unless-stopped volumes: diff --git a/scripts/download-practice-resources.ps1 b/scripts/download-practice-resources.ps1 index 62f900f..5e306e6 100644 --- a/scripts/download-practice-resources.ps1 +++ b/scripts/download-practice-resources.ps1 @@ -10,10 +10,11 @@ param( [switch]$SkipAwsCliInstall ) -# Repository-distributed credential restricted to read/list access for 20277-gal-res. -# It cannot access other buckets or prefixes and cannot create, replace, or delete objects. -$oscaAccessKeyId = 'ZMVBNId4c52092lL53Jg' -$oscaSecretAccessKey = 'upDcWNLGKlhe2qVSKOYG' +# Credentials must be provided via environment variables. +# The OSCA S3 credentials are intentionally NOT stored in this repository. +# Set OSCA_ACCESS_KEY_ID and OSCA_SECRET_ACCESS_KEY before running this script. +$oscaAccessKeyId = $env:OSCA_ACCESS_KEY_ID +$oscaSecretAccessKey = $env:OSCA_SECRET_ACCESS_KEY $ErrorActionPreference = 'Stop' $projectRoot = Split-Path -Parent $PSScriptRoot @@ -115,7 +116,7 @@ Write-Host "Using AWS CLI v2: $awsExecutable" $accessKey = $oscaAccessKeyId $secretKey = $oscaSecretAccessKey if ([string]::IsNullOrWhiteSpace($accessKey) -or [string]::IsNullOrWhiteSpace($secretKey)) { - throw 'The owner-distributed OSCA credentials are missing from the top of this script.' + throw 'OSCA credentials are missing. Set the OSCA_ACCESS_KEY_ID and OSCA_SECRET_ACCESS_KEY environment variables before running this script.' } $destinationRoot = [IO.Path]::GetFullPath($DestinationPath)