Fix security audit findings: admin authz, captcha, secret leak, CORS
All checks were successful
build-and-release / build (developers) (push) Successful in 5m31s
build-and-release / build (catering-services) (push) Successful in 5m56s
build-and-release / build (employees) (push) Successful in 6m23s
build-and-release / build (companies) (push) Successful in 6m31s
build-and-release / build (cron) (push) Successful in 6m38s
build-and-release / build (customers) (push) Successful in 7m19s
build-and-release / build (gateway) (push) Successful in 1m28s
build-and-release / build (fitness-trainers) (push) Successful in 2m2s
build-and-release / build (jobs) (push) Successful in 1m12s
build-and-release / build (graphic-designers) (push) Successful in 2m20s
build-and-release / build (job-seekers) (push) Successful in 2m24s
build-and-release / build (photographers) (push) Successful in 2m20s
build-and-release / build (makeup-artists) (push) Successful in 2m55s
build-and-release / build (tutors) (push) Successful in 2m15s
build-and-release / build (payments) (push) Successful in 3m48s
build-and-release / build (ugc-content-creators) (push) Successful in 2m35s
build-and-release / build (social-media-managers) (push) Successful in 4m46s
build-and-release / build (video-editors) (push) Successful in 2m41s
build-and-release / build (users) (push) Successful in 8m7s

- Require admin role on role/module/permission management endpoints
  that previously accepted any authenticated user (privilege escalation)
- Add server-side captcha generation/verification (Redis-backed,
  single-use, 5 min TTL) enforced on register/login for users and
  employees services
- Untrack .env.test111 (contained a live SMTP key) and harden
  .gitignore against future .env commits
- Stop logging OTP codes in plaintext
- Restrict jobs service CORS to an explicit origin allowlist
- Mask PayU merchant secret/salt in payment-gateway-config responses,
  preserving the stored value on save when the field is left unchanged
- Bump vulnerable transitive dependencies (quinn-proto, rustls-webpki,
  anyhow) via cargo update; switch aws-sdk-s3 off the legacy rustls
  feature

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-23 17:31:48 +05:30
parent 73beb03368
commit fdd5c7a418
12 changed files with 634 additions and 883 deletions

View file

@ -1,79 +0,0 @@
# Nxtgauge Backend — Environment Variables for test111.nxtgauge.com
# Copy this file to .env and fill in the actual values.
# ── Database ─────────────────────────────────────────────────────────────────
POSTGRES_PASSWORD=nxtgauge_dev
DATABASE_URL=postgresql://nxtgauge:nxtgauge_dev@localhost:5432/nxtgauge_db
# ── Auth ──────────────────────────────────────────────────────────────────────
# Generate with: openssl rand -base64 64
JWT_SECRET=change-me-to-a-secure-random-string-of-at-least-64-chars
JWT_EXPIRY_MINUTES=15
REFRESH_TOKEN_EXPIRY_DAYS=30
# ── SMTP (ZeptoMail/Zoho) ──────────────────────────────────────────────────
# These are the ZeptoMail configuration values for sending emails
# Username is always "emailapikey" for ZeptoMail SMTP
# Password is your ZeptoMail API key
EMAIL_PROVIDER=SMTP
SMTP_HOST=smtp.zeptomail.in
SMTP_PORT=587
SMTP_USER=emailapikey
SMTP_PASS=PHtE6r1ZR+zi3jV88RNW4/O4F8CkPdksqO9iJAhA4YcTD6dQFk1S+dl/wDC3/h97AKYWFfSczo1rt72etOuDLTnrMjlEDWqyqK3sx/VYSPOZsbq6x00esVgYdEfYVYDpcNFj3SPQut7dNA==
SMTP_FROM_EMAIL=support@nxtgauge.com
SMTP_FROM_NAME=NXTGAUGE
SMTP_SECURE=true
SMTP_REPLY_TO=support@nxtgauge.com
# ── Demo Account Emails (comma-separated) ───────────────────────────────────
# These emails bypass email verification (for testing)
DEMO_ACCOUNT_EMAILS=test@example.com,demo@nxtgauge.com
# ── Object Storage (Backblaze B2 S3-Compatible) ─────────────────────────────
B2_BUCKET_NAME=Nxtgauge-object
B2_REGION=eu-central-003
B2_ENDPOINT=https://s3.eu-central-003.backblazeb2.com
B2_ACCESS_KEY_ID=replace-with-b2-key-id
B2_SECRET_ACCESS_KEY=replace-with-b2-secret
B2_USE_PATH_STYLE=true
# ── Payments ──────────────────────────────────────────────────────────────────
RAZORPAY_KEY_ID=rzp_test_...
RAZORPAY_KEY_SECRET=...
# ── Frontend ──────────────────────────────────────────────────────────────────
FRONTEND_URL=https://test111.nxtgauge.com
ADMIN_URL=https://admin.test111.nxtgauge.com
# ── Service Ports (local development, for running services individually) ──────
GATEWAY_PORT=9100
USERS_PORT=9101
COMPANIES_PORT=9102
JOB_SEEKERS_PORT=9104
CUSTOMERS_PORT=9105
PHOTOGRAPHERS_PORT=9107
MAKEUP_ARTISTS_PORT=9109
TUTORS_PORT=9108
DEVELOPERS_PORT=9110
VIDEO_EDITORS_PORT=9111
GRAPHIC_DESIGNERS_PORT=9112
SOCIAL_MEDIA_MANAGERS_PORT=9113
FITNESS_TRAINERS_PORT=9114
CATERING_SERVICES_PORT=9115
PAYMENTS_PORT=9116
# ── Service URLs (used by gateway — override only for non-Docker dev) ─────────
USERS_SERVICE_URL=http://localhost:9101
COMPANIES_SERVICE_URL=http://localhost:9102
JOB_SEEKERS_SERVICE_URL=http://localhost:9104
CUSTOMERS_SERVICE_URL=http://localhost:9105
PHOTOGRAPHERS_SERVICE_URL=http://localhost:9107
MAKEUP_ARTISTS_SERVICE_URL=http://localhost:9109
TUTORS_SERVICE_URL=http://localhost:9108
DEVELOPERS_SERVICE_URL=http://localhost:9110
VIDEO_EDITORS_SERVICE_URL=http://localhost:9111
GRAPHIC_DESIGNERS_SERVICE_URL=http://localhost:9112
SOCIAL_MEDIA_MANAGERS_SERVICE_URL=http://localhost:9113
FITNESS_TRAINERS_SERVICE_URL=http://localhost:9114
CATERING_SERVICES_SERVICE_URL=http://localhost:9115
PAYMENTS_SERVICE_URL=http://localhost:9116

3
.gitignore vendored
View file

@ -2,6 +2,9 @@
.env
.env.local
.env.production
.env.test111
.env*
!.env.example
# Rust build artifacts
/target

1163
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -33,6 +33,8 @@ pub fn router() -> Router<AppState> {
pub struct LoginPayload {
pub email: String,
pub password: String,
pub captcha_id: String,
pub captcha_answer: String,
}
#[derive(Serialize)]
@ -64,6 +66,11 @@ async fn login(
let email = payload.email.to_lowercase();
let mut redis = state.redis.clone();
// Captcha must be verified (and is consumed, single-use) before anything else.
if !cache::captcha::verify(&mut redis, &payload.captcha_id, &payload.captcha_answer).await {
return Err(err(StatusCode::BAD_REQUEST, "Invalid or expired captcha", "CAPTCHA_FAILED"));
}
if !cache::rate_limit::check_admin_login(&mut redis, &email).await.unwrap_or(true) {
return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many login attempts. Try again in 15 minutes.", "RATE_LIMITED"));
}
@ -243,7 +250,7 @@ async fn forgot_password(
};
let code = format!("{:06}", rand::random::<u32>() % 1_000_000);
tracing::info!(otp = %code, email = %employee.email, "OTP generated for employee password reset");
tracing::info!(email = %employee.email, "OTP generated for employee password reset");
cache::token::store_employee_reset(&mut redis, &code, &employee.id.to_string())
.await

View file

@ -3,7 +3,7 @@
use axum::{
extract::State,
http::StatusCode,
http::{HeaderValue, Method, StatusCode},
routing::get,
Json, Router,
};
@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Clone)]
@ -91,6 +91,31 @@ async fn health() -> &'static str {
"Jobs Service OK"
}
fn build_cors() -> CorsLayer {
let frontend_url = std::env::var("FRONTEND_URL")
.expect("FRONTEND_URL must be set");
let admin_url = std::env::var("ADMIN_URL")
.expect("ADMIN_URL must be set");
let allowed_origins: Vec<HeaderValue> = vec![
frontend_url.parse().expect("Invalid FRONTEND_URL"),
admin_url.parse().expect("Invalid ADMIN_URL"),
];
CorsLayer::new()
.allow_origin(AllowOrigin::list(allowed_origins))
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers(AllowHeaders::mirror_request())
.allow_credentials(true)
}
#[tokio::main]
async fn main() {
tracing_subscriber::registry()
@ -113,10 +138,7 @@ async fn main() {
let state = Arc::new(AppState { pool });
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let cors = build_cors();
let app = Router::new()
.route("/health", get(health))

View file

@ -17,6 +17,7 @@ use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/captcha", post(generate_captcha))
.route("/check-email", post(check_email))
.route("/register", post(register))
.route("/login", post(login))
@ -49,12 +50,22 @@ pub struct RegisterPayload {
pub intent: Option<String>,
#[serde(alias = "role_key", alias = "roleKey")]
pub profession: Option<String>,
pub captcha_id: String,
pub captcha_answer: String,
}
#[derive(Deserialize)]
pub struct LoginPayload {
pub email: String,
pub password: String,
pub captcha_id: String,
pub captcha_answer: String,
}
#[derive(Serialize)]
pub struct CaptchaResponse {
pub captcha_id: String,
pub challenge: String,
}
#[derive(Deserialize)]
@ -231,6 +242,28 @@ async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option<Uuid
// ── Handlers ──────────────────────────────────────────────────────────────────
/// POST /api/auth/captcha
///
/// Generates a short-lived (5 min) math challenge, stores the expected answer
/// server-side keyed by a random `captcha_id`, and returns the id + human-readable
/// challenge text. The client must echo both `captcha_id` and `captcha_answer`
/// back on signup/login; the answer is verified and consumed (single-use) there.
async fn generate_captcha(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let mut redis = state.redis.clone();
let challenge = cache::captcha::generate();
cache::captcha::set(&mut redis, &challenge.captcha_id, &challenge.answer)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
Ok((StatusCode::OK, Json(CaptchaResponse {
captcha_id: challenge.captcha_id,
challenge: challenge.challenge,
})))
}
/// POST /api/auth/check-email
async fn check_email(
State(state): State<AppState>,
@ -275,6 +308,11 @@ async fn register(
let email = payload.email.to_lowercase();
let mut redis = state.redis.clone();
// Captcha must be verified (and is consumed, single-use) before anything else.
if !cache::captcha::verify(&mut redis, &payload.captcha_id, &payload.captcha_answer).await {
return Err(err(StatusCode::BAD_REQUEST, "Invalid or expired captcha", "CAPTCHA_FAILED"));
}
// Rate limit: max 10 registrations per hour per email
if !cache::rate_limit::check_register(&mut redis, &email).await.unwrap_or(true) {
return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many registration attempts. Try again later.", "RATE_LIMITED"));
@ -423,7 +461,7 @@ async fn register(
// Store OTP in Redis (15-min TTL, keyed by code → user_id)
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
tracing::info!(otp = %otp, email = %email, "OTP generated for registration");
tracing::info!(email = %email, "OTP generated for registration");
cache::otp::set(&mut redis, &otp, &user.id.to_string())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
@ -461,6 +499,11 @@ async fn login(
let is_demo_account = is_dummy_account_email(&email);
let mut redis = state.redis.clone();
// Captcha must be verified (and is consumed, single-use) before anything else.
if !cache::captcha::verify(&mut redis, &payload.captcha_id, &payload.captcha_answer).await {
return Err(err(StatusCode::BAD_REQUEST, "Invalid or expired captcha", "CAPTCHA_FAILED"));
}
// Demo logins must not depend on Redis because the live demo path needs to work
// even when cache persistence is degraded.
if !is_demo_account && !cache::rate_limit::check_login(&mut redis, &email).await.unwrap_or(true) {
@ -718,7 +761,7 @@ async fn resend_otp(
}
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
tracing::info!(otp = %otp, email = %user.email, "OTP generated for resend");
tracing::info!(email = %user.email, "OTP generated for resend");
cache::otp::set(&mut redis, &otp, &user.id.to_string())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
@ -760,7 +803,7 @@ async fn forgot_password(
};
let code = format!("{:06}", rand::random::<u32>() % 1_000_000);
tracing::info!(otp = %code, email = %user.email, "OTP generated for password reset");
tracing::info!(email = %user.email, "OTP generated for password reset");
cache::token::store_reset(&mut redis, &code, &user.id.to_string())
.await

View file

@ -8,7 +8,7 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use sqlx::types::Uuid;
use contracts::auth_middleware::AuthUser;
use contracts::auth_middleware::{require_admin, AuthUser};
pub fn persona_types_router() -> Router<AppState> {
Router::new()
@ -66,9 +66,11 @@ struct ModuleRow {
}
async fn list_modules(
_auth: AuthUser,
auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let rows = sqlx::query_as::<_, ModuleRow>(
r#"
SELECT id, module_key, module_name, category, description,
@ -99,10 +101,12 @@ struct RoleModuleAccessRow {
}
async fn get_role_modules(
_auth: AuthUser,
auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let rows = sqlx::query_as::<_, RoleModuleAccessRow>(
r#"
SELECT rma.id, rma.module_id, m.module_key, m.module_name,
@ -132,11 +136,13 @@ struct AddModulePayload {
}
async fn add_role_module(
_auth: AuthUser,
auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
Json(payload): Json<AddModulePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let is_enabled = payload.is_enabled.unwrap_or(true);
let is_sidebar_visible = payload.is_sidebar_visible.unwrap_or(true);
@ -165,10 +171,12 @@ async fn add_role_module(
}
async fn remove_role_module(
_auth: AuthUser,
auth: AuthUser,
State(state): State<AppState>,
Path((role_id, module_id)): Path<(Uuid, Uuid)>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let result = sqlx::query(
"DELETE FROM role_module_access WHERE role_id = $1 AND module_id = $2",
)
@ -200,10 +208,12 @@ struct RolePermissionRow {
}
async fn get_role_permissions(
_auth: AuthUser,
auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let rows = sqlx::query_as::<_, RolePermissionRow>(
r#"
SELECT rmp.id, rmp.module_id, m.module_key, m.module_name, m.category,
@ -230,11 +240,13 @@ struct UpdatePermissionPayload {
}
async fn update_role_permission(
_auth: AuthUser,
auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
Json(payload): Json<UpdatePermissionPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Admin access required".to_string()))?;
let permission_col = match payload.permission.as_str() {
"view" => "can_view",
"list" => "can_list",

View file

@ -10,6 +10,28 @@ use contracts::auth_middleware::{require_admin, AuthUser};
use serde::{Deserialize, Serialize};
const PAYU_GATEWAY_KEY: &str = "PAYU";
const MASK_CHAR: char = '\u{2022}'; // •
/// Masks a secret so only its last 4 characters are ever exposed to the browser,
/// e.g. "••••••••3f2a". Never returns the real value.
fn mask_secret(secret: &str) -> String {
if secret.is_empty() {
return String::new();
}
let chars: Vec<char> = secret.chars().collect();
if chars.len() <= 4 {
return MASK_CHAR.to_string().repeat(chars.len().max(4));
}
let last4: String = chars[chars.len() - 4..].iter().collect();
format!("{}{}", MASK_CHAR.to_string().repeat(8), last4)
}
/// True if the value looks like a masked placeholder (contains the mask char),
/// meaning the admin did not actually type a new secret and the field should be
/// left untouched server-side rather than overwritten with the placeholder.
fn is_masked_placeholder(value: &str) -> bool {
value.chars().any(|c| c == MASK_CHAR) || value.trim().is_empty()
}
pub fn router() -> Router<AppState> {
Router::new()
@ -39,6 +61,14 @@ struct PaymentGatewayRow {
is_active: bool,
}
#[derive(Debug, sqlx::FromRow)]
struct PaymentGatewayRowWithId {
id: uuid::Uuid,
display_name: Option<String>,
config_json: Option<serde_json::Value>,
is_active: bool,
}
impl Default for PaymentGatewayConfigPayload {
fn default() -> Self {
Self {
@ -150,7 +180,7 @@ async fn get_payment_gateway_config(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let payload = match row {
let mut payload = match row {
Some(row) => row_to_payload(row),
None => {
let mut defaults = PaymentGatewayConfigPayload::default();
@ -160,6 +190,10 @@ async fn get_payment_gateway_config(
}
};
// Never send the real merchant salt to the browser — mask it down to its
// last 4 characters. The full value stays server-side only.
payload.secret_key = mask_secret(&payload.secret_key);
Ok((StatusCode::OK, Json(serde_json::json!({ "config": payload }))))
}
@ -170,7 +204,37 @@ async fn upsert_payment_gateway_config(
) -> Result<impl IntoResponse, (StatusCode, String)> {
require_admin(&auth).map_err(|_| (StatusCode::FORBIDDEN, "Forbidden".to_string()))?;
let payload = normalize_payload(payload);
let mut payload = normalize_payload(payload);
// Look up the currently stored row first, both to know whether to INSERT vs
// UPDATE and to recover the real secret if the admin submitted the masked
// placeholder back unchanged (the browser never has the real value to send).
let existing_row = sqlx::query_as::<_, PaymentGatewayRowWithId>(
r#"
SELECT id, display_name, config_json, is_active
FROM payment_gateway_configs
WHERE gateway_key = $1
ORDER BY created_at DESC
LIMIT 1
"#,
)
.bind(PAYU_GATEWAY_KEY)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
if is_masked_placeholder(&payload.secret_key) {
// Admin didn't actually change the salt — keep the real stored value
// instead of overwriting it with the masked placeholder string.
let existing_secret_key = existing_row
.as_ref()
.and_then(|r| r.config_json.as_ref())
.and_then(|c| c.get("secret_key"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
payload.secret_key = existing_secret_key;
}
let config_json = serde_json::json!({
"mode": payload.mode,
@ -182,19 +246,7 @@ async fn upsert_payment_gateway_config(
"secret_key": payload.secret_key,
});
let existing_id = sqlx::query_scalar::<_, uuid::Uuid>(
r#"
SELECT id
FROM payment_gateway_configs
WHERE gateway_key = $1
ORDER BY created_at DESC
LIMIT 1
"#,
)
.bind(PAYU_GATEWAY_KEY)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let existing_id = existing_row.map(|r| r.id);
let row = if let Some(id) = existing_id {
sqlx::query_as::<_, PaymentGatewayRow>(
@ -231,11 +283,16 @@ async fn upsert_payment_gateway_config(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
};
let mut response_payload = row_to_payload(row);
// Never echo the real salt back in the save response either — mask it
// just like the GET endpoint does.
response_payload.secret_key = mask_secret(&response_payload.secret_key);
Ok((
StatusCode::OK,
Json(serde_json::json!({
"message": "Payment gateway configuration saved successfully.",
"config": row_to_payload(row),
"config": response_payload,
})),
))
}

View file

@ -13,3 +13,4 @@ tracing = { workspace = true }
thiserror = { workspace = true }
reqwest = { workspace = true }
lazy_static = "1.4"
rand = "0.8"

61
crates/cache/src/captcha.rs vendored Normal file
View file

@ -0,0 +1,61 @@
//! Server-side CAPTCHA challenge generation, storage, and verification.
//!
//! Keys
//! ────
//! `captcha:{captcha_id}` → expected answer string, TTL 5 min, single-use (GETDEL on verify)
use rand::Rng;
use redis::AsyncCommands;
use crate::RedisPool;
const CAPTCHA_TTL_SECS: u64 = 300; // 5 minutes
/// A freshly generated challenge, ready to be persisted and returned to the client.
pub struct Challenge {
pub captcha_id: String,
pub challenge: String,
/// Expected answer — never sent to the client, only stored server-side.
pub answer: String,
}
/// Generate a small arithmetic challenge (e.g. "7 + 12 = ?") with a random id.
pub fn generate() -> Challenge {
let mut rng = rand::thread_rng();
let a: i32 = rng.gen_range(1..=20);
let b: i32 = rng.gen_range(1..=20);
// Randomly pick + or - (subtraction always ordered so the answer is non-negative).
let (challenge, answer) = if rng.gen_bool(0.5) {
(format!("{a} + {b} = ?"), a + b)
} else {
let (hi, lo) = if a >= b { (a, b) } else { (b, a) };
(format!("{hi} - {lo} = ?"), hi - lo)
};
Challenge {
captcha_id: uuid::Uuid::new_v4().to_string(),
challenge,
answer: answer.to_string(),
}
}
/// Store the expected answer for a captcha_id. TTL 5 min.
pub async fn set(redis: &mut RedisPool, captcha_id: &str, answer: &str) -> Result<(), redis::RedisError> {
let key = format!("captcha:{captcha_id}");
redis.set_ex::<_, _, ()>(key, answer, CAPTCHA_TTL_SECS).await
}
/// Atomically fetch and delete the expected answer for a captcha_id (single-use).
/// Returns `None` if the id doesn't exist or has expired.
pub async fn consume(redis: &mut RedisPool, captcha_id: &str) -> Result<Option<String>, redis::RedisError> {
let key = format!("captcha:{captcha_id}");
redis.get_del(key).await
}
/// Verify (and consume, single-use, regardless of outcome) a client-submitted answer.
/// Returns `true` only if the captcha_id existed, hadn't expired, and the answer matched.
pub async fn verify(redis: &mut RedisPool, captcha_id: &str, submitted_answer: &str) -> bool {
match consume(redis, captcha_id).await {
Ok(Some(expected)) => expected.trim().eq_ignore_ascii_case(submitted_answer.trim()),
_ => false,
}
}

View file

@ -1,4 +1,5 @@
pub mod ai;
pub mod captcha;
pub mod client;
pub mod ollama;
pub mod otp;

View file

@ -10,7 +10,7 @@ serde = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true }
reqwest = { version = "0.12", features = ["json", "multipart"] }
aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio", "rustls"] }
aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio", "default-https-client"] }
aws-config = { version = "1", default-features = false, features = ["rt-tokio", "rustls"] }
aws-credential-types = "1"
bytes = "1"