chore(history): squash 100 commit(s) from 2026-09-24
- fix(backend): case-insensitive unique email, revoke PUBLIC schema access, pin Argon2id params - test(backend): assert password length cap boundary (256 ok, 257 rejected) - docs(backend): plan — typed JWT token kinds so refresh/device tokens cannot pass as access tokens - feat(backend): JWT access/refresh token issue and verify - feat(backend): accounts repository (create/find_by_email/find_by_id) - refactor(gui): LoVisualAddonManagerScreen 989→10 файлов addon/ (8.5.2) - docs(backend): plan — fix sqlx::migrate! path in integration tests - feat(backend): POST /auth/register and /auth/login - refactor(settings): SettingsPanelComponent 715→81 + 6 helpers (8.5.2) - docs(backend): plan — harden device flow (single-use codes, bounded store, 404/429) - feat(backend): OAuth device authorization grant for mod login - fix(backend): first confirm wins for device codes - docs(backend): plan — split Task 8 (refactor) and Task 9 (avatars), harden avatar handling - refactor(mixins): LocalPlayerMixin 691→111 + 4 handlers (8.5.2) - refactor(visuals): Trails 687→130 (8.5.2) - refactor(render): ItemBatchRenderer 677->100 (8.5.2) - refactor(visuals): ReimaginedVisual 674→118 + 5 helpers (8.5.2) - refactor(config): ConfigSerializer 668→91 + 4 helpers (8.5.2) - refactor(hud): DynamicIsland 661→158 + 4 helpers (8.5.2) - refactor(render): GlStencilFramebufferSupport 666→169 (8.5.2) - refactor(gui): MenuScreen 669→128 + 4 helpers (8.5.2) - refactor(render): UiStyle 644→170 + 3 helpers (8.5.2) - refactor(gui): ModuleComponent 613→98 + 4 helpers (8.5.2) - refactor(media): MediaSessionService 616→200 + 4 helpers (8.5.2) - refactor(gui): RelationsComponent 661→59 + 4 helpers (8.5.2) - chore(license): strip GPL file headers from all Java sources - refactor(aiming): PointTracker 583→168 + 2 helpers (8.5.2) - refactor(gui): LoVisualProxyManagerScreen 591→132 + 2 helpers (8.5.2) - refactor(visuals): KillEffect 588→96 + 4 helpers (8.5.2) - refactor(render): MeshBuilder +4 helpers (8.5.2) - refactor(visuals): extract WorldParticlesRender helper (8.5.2) - refactor(world): ExplosionDamageUtil 551→116 + 2 helpers (8.5.2) - refactor(visuals): TazikHat 596->179 + Model + Palette in hats/tazik (8.5.2) - chore(license): strip GPL header from remaining 30 files and make strip script variant-aware - refactor(gui): ThemeComponent 561->166 + CardRenderer + ScrollState (8.5.2) - refactor(hud): CustomHotbar 556→178 + Renderer + Selection + SelectionGradient (8.5.2) - refactor(gui): ThemeCardRenderer perf + readability polish - refactor(clickgui): CooldownRulesSetting 596->198 + Editor + DetailRenderer (8.5.2) - refactor(hud): HudNotifier 561->200 + Painter + runtime/HudNotifierRuntime (8.5.2) - refactor(theme): Themes 555->168 + impl/Transition + impl/Blending + impl/ProfileCodec (8.5.2) - refactor(theme): EditableClickGuiTheme 205->185 + JavaDoc (8.5.2) - refactor(theme): ThemeStore 491->128 + store/ThemeStoreJson + store/ThemeStoreIO (8.5.2) - refactor(clickgui): ClickGuiRenderer 604->200 compacted one-line delegators + JavaDoc (8.5.2) - refactor(mainmenu): LoVisualMainMenuScreen 551->161 + impl/Painter + impl/Renderer + impl/TextUtil (8.5.2) - refactor(clickgui): ClickGuiTextEditorState 531->187 + impl/EditorCaret + impl/EditorPainter (8.5.2) - refactor(tab): TabListModel 525->139 + model/Collector + model/Reader + model/Signature + model/TextSplitter (8.5.2) - refactor(backend): shared bearer helper and test helpers, build_app takes Config, validate JWT secret strength - refactor(module): ModuleManager 521->198 + impl/Registrar + impl/Dispatcher (8.5.2) - feat(backend): avatar upload with decode, square crop, PNG re-encode and S3 storage - refactor(clip): ClipFunction 512->146 + impl/Geometry + impl/Debug (8.5.2) - docs(backend): implementation plans for gateway (auth hardening, gRPC, rate limits) and configs-service - refactor(iris-patch): ShaderPatchEngine 499->146 + impl/Repo (8.5.2) - chore(frontend): add router, react-query, fonts and vitest; dev proxy to gateway - refactor(hud): ScriptedListHudPanel 499->158 + panel/Props + panel/Signature (8.5.2) - refactor(hud): BaseHudElement 499->199 + impl/Registry + impl/Namer + impl/Prewarm (8.5.2) - refactor(clickgui): Setting 498->170 + impl/Localization + impl/I18n (8.5.2) - refact(viewmodel): split swing animations into camera/swing package - refact(kineticlyrics): split module into stage, playback and modes - rename(holeesp): module HoleESP -> CrystalHoles - refact(crystalholes): split module into crystal scanner, renderer and safety - refact(addonmanager): split manager into lifecycle, runtime, descriptors and profiles - refact(accountconfig): split config into store, session and value helpers - refactor(render): CustomTextRenderer 229->195, extract glyph-pass into GradientTexts helper - docs(TODO): mark AddonManager split done; close 9.2 refactor gate - refactor(media): LinuxMediaSession 441->148, split reader + track/seek state - refactor(nametags): split NameTags into facade + impl helpers - refactor(clickgui): split MainSettingsComponent into facade + scroll + model - refactor(hud): split CustomBar into facade, model and BarSettings - docs(frontend): implementation plan with design system from the mod theme - feat(frontend): design tokens from the mod theme, fonts and shared UI kit - fix(accounts): run migrations on startup, offload Argon2, validate register input, JSON error shape - docs(gateway): plan note on splitting auth handlers before refresh endpoints - feat(frontend): API client with silent refresh, error descriptions and test helpers - style(mod): group compact one-line bulk query methods in ModuleManager - feat(frontend): session restore, login and registration with client-side validation - refactor(hud): split CustomHealthBar into facade + painter + script renderer - docs(mod): record the 2026-09-24 HUD/settings split wave in TODO phase 8.5 - refactor(rhi): split GlStencilShapeClipBackend into facade + native-state + pass-lifecycle helpers - refactor(rhi): split VulkanRenderStateBridge into facade + MSAA and stencil state helpers - refactor(backtrack): split BacktrackController into facade + model + impl helpers - refactor(svg): split SvgPathParser into facade + arc geometry + command/curve helpers - refactor(mixin): split ClientPacketListenerMixin into hook-only mixin + handlers - refactor(renderer3d): un-nest batch bindings + culling into sibling impl types - refactor(renderwarp): extract static factories + geometry into impl helpers - feat(backend): add common crate with shared JWT, internal gateway contract and accounts proto - refactor(guimixin): move hook bodies into handlers, keep mixin as hooks + shadows - feat(accounts): accept only gateway traffic, read identity from gateway header - refactor(cacheduiscriptruntime): extract engine, hashing and frame stats into impl - refactor(customskyboxrenderer): extract projection, shader passes and sun into impl - refactor(betterchatstoremanager): extract persistence, key/path and hover helpers into impl - feat(accounts): rotating opaque refresh tokens in httpOnly cookie, /auth/refresh and /auth/logout - refactor(targetesp): extract crystal rendering subsystem into impl/TargetEspCrystalRenderer - refactor(betterchathovercache): extract disk codec and lookup indexing into impl/ChatHoverCacheCodec - refactor(microsoftauth): split HTTP transport, device-code and Xbox flows into impl/ - refactor(pvpcooldowns): extract local item-rule engine and defaults into impl/PvpCooldownRules - refactor(lovisual): extract HUD/world render orchestration into HudRender helper - refactor(statuseffectheuristics): extract palette/inference into ParticlePalette and color utils into ParticleColors - refactor(dropesp): extract overlay/label render subsystem into impl/DropEspOverlayRenderer - refactor(proxy): extract SOCKS handshake message builders into ProxyProtocolMessages - refactor(eagleutil): promote EdgeRecovery controller and RecoveryMode to top-level class
This commit is contained in:
parent
9d08fa910a
commit
72bc4c7148
1897 changed files with 36199 additions and 39289 deletions
2
backend/accounts-service/src/accounts/mod.rs
Normal file
2
backend/accounts-service/src/accounts/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod model;
|
||||
pub mod repo;
|
||||
15
backend/accounts-service/src/accounts/model.rs
Normal file
15
backend/accounts-service/src/accounts/model.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||
pub struct Account {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
pub display_nick: String,
|
||||
pub role: String,
|
||||
pub can_publish_addons: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
168
backend/accounts-service/src/accounts/repo.rs
Normal file
168
backend/accounts-service/src/accounts/repo.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
use super::model::Account;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Emails are stored lowercase and compared via the `lower(email)` unique
|
||||
// index (see migrations/0001_init.sql). Every entry point normalizes here.
|
||||
pub fn normalize_email(email: &str) -> String {
|
||||
email.trim().to_lowercase()
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
email: &str,
|
||||
password_hash: &str,
|
||||
nick: &str,
|
||||
) -> Result<Account, sqlx::Error> {
|
||||
sqlx::query_as::<_, Account>(
|
||||
"INSERT INTO accounts (email, password_hash, display_nick)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, password_hash, display_nick, role, can_publish_addons, created_at",
|
||||
)
|
||||
.bind(normalize_email(email))
|
||||
.bind(password_hash)
|
||||
.bind(nick)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<Option<Account>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Account>(
|
||||
"SELECT id, email, password_hash, display_nick, role, can_publish_addons, created_at
|
||||
FROM accounts WHERE lower(email) = $1",
|
||||
)
|
||||
.bind(normalize_email(email))
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Account>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Account>(
|
||||
"SELECT id, email, password_hash, display_nick, role, can_publish_addons, created_at
|
||||
FROM accounts WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_avatar(pool: &PgPool, account_id: Uuid, s3_key: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO avatars (account_id, s3_key) VALUES ($1, $2)
|
||||
ON CONFLICT (account_id) DO UPDATE SET s3_key = EXCLUDED.s3_key, uploaded_at = now()",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(s3_key)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn test_pool() -> PgPool {
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
|
||||
let pool = PgPool::connect(&url).await.expect("connect");
|
||||
sqlx::migrate!("./migrations").run(&pool).await.expect("migrate");
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_then_find_by_email_round_trips() {
|
||||
let pool = test_pool().await;
|
||||
let email = format!("test-{}@example.com", Uuid::new_v4());
|
||||
|
||||
let created = create(&pool, &email, "hash123", "TestNick").await.unwrap();
|
||||
assert_eq!(created.email, email);
|
||||
assert_eq!(created.role, "user");
|
||||
assert!(created.can_publish_addons);
|
||||
|
||||
let found = find_by_email(&pool, &email).await.unwrap().expect("must exist");
|
||||
assert_eq!(found.id, created.id);
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE id = $1")
|
||||
.bind(created.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_by_email_returns_none_for_missing() {
|
||||
let pool = test_pool().await;
|
||||
let result = find_by_email(&pool, "does-not-exist@example.com").await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_lookup_is_case_insensitive_and_stored_lowercase() {
|
||||
let pool = test_pool().await;
|
||||
let tag = Uuid::new_v4();
|
||||
let mixed = format!("MiXeD-{tag}@Example.COM");
|
||||
|
||||
let created = create(&pool, &mixed, "hash123", "Nick").await.unwrap();
|
||||
assert_eq!(created.email, mixed.to_lowercase());
|
||||
|
||||
let found = find_by_email(&pool, &mixed.to_uppercase())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("lookup must ignore case");
|
||||
assert_eq!(found.id, created.id);
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE id = $1")
|
||||
.bind(created.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_email_differing_only_by_case_is_rejected() {
|
||||
let pool = test_pool().await;
|
||||
let tag = Uuid::new_v4();
|
||||
let first = create(&pool, &format!("dup-{tag}@example.com"), "h", "A").await.unwrap();
|
||||
|
||||
let second = create(&pool, &format!("DUP-{tag}@EXAMPLE.com"), "h", "B").await;
|
||||
let err = second.expect_err("case-variant duplicate must violate the unique index");
|
||||
match err {
|
||||
sqlx::Error::Database(db) => {
|
||||
assert_eq!(db.constraint(), Some("accounts_email_lower_idx"));
|
||||
}
|
||||
other => panic!("expected a database unique violation, got {other:?}"),
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE id = $1")
|
||||
.bind(first.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_avatar_inserts_then_updates_in_place() {
|
||||
let pool = test_pool().await;
|
||||
let created = create(&pool, &format!("av-{}@example.com", Uuid::new_v4()), "h", "N")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
set_avatar(&pool, created.id, "avatars/one.png").await.unwrap();
|
||||
set_avatar(&pool, created.id, "avatars/two.png").await.unwrap();
|
||||
|
||||
let rows: Vec<(String,)> =
|
||||
sqlx::query_as("SELECT s3_key FROM avatars WHERE account_id = $1")
|
||||
.bind(created.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![("avatars/two.png".to_string(),)]);
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE id = $1")
|
||||
.bind(created.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
227
backend/accounts-service/src/auth/handlers.rs
Normal file
227
backend/accounts-service/src/auth/handlers.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
use crate::accounts::repo;
|
||||
use crate::auth::{password, tokens};
|
||||
use crate::error::{AppError, AppJson};
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use common::jwt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub pool: sqlx::PgPool,
|
||||
pub jwt_secret: String,
|
||||
pub cookie_secure: bool,
|
||||
pub hasher: password::PasswordHasher,
|
||||
// A real Argon2id hash of a throwaway string. `login` verifies against
|
||||
// it when the email is unknown so that "no such account" costs the same
|
||||
// ~100ms as "wrong password" — otherwise response time leaks which
|
||||
// emails are registered (user enumeration via timing).
|
||||
dummy_hash: String,
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
pub fn new(pool: sqlx::PgPool, jwt_secret: String, cookie_secure: bool) -> Self {
|
||||
// Hashing a fixed, short constant with fixed valid params cannot
|
||||
// fail; this is not user input, so the expect is a startup invariant.
|
||||
let dummy_hash = password::hash_password("timing-equalizer-not-a-real-password")
|
||||
.expect("hashing a constant with pinned params cannot fail");
|
||||
AuthState {
|
||||
pool,
|
||||
jwt_secret,
|
||||
cookie_secure,
|
||||
hasher: password::PasswordHasher::new(),
|
||||
dummy_hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub nick: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterResponse {
|
||||
pub id: uuid::Uuid,
|
||||
pub email: String,
|
||||
pub display_nick: String,
|
||||
}
|
||||
|
||||
/// Validates and normalizes a register request. Returns the trimmed
|
||||
/// `(email, nick)` on success.
|
||||
fn validate_register(req: &RegisterRequest) -> Result<(String, String), AppError> {
|
||||
let email = req.email.trim();
|
||||
if email.is_empty() {
|
||||
return Err(AppError::Validation("email must not be empty".into()));
|
||||
}
|
||||
if email.len() > 254 {
|
||||
return Err(AppError::Validation("email must be at most 254 characters".into()));
|
||||
}
|
||||
let mut parts = email.split('@');
|
||||
let (Some(local), Some(domain)) = (parts.next(), parts.next()) else {
|
||||
return Err(AppError::Validation("email must contain '@'".into()));
|
||||
};
|
||||
if parts.next().is_some() || local.is_empty() || domain.is_empty() {
|
||||
return Err(AppError::Validation("email must have exactly one '@' with non-empty parts".into()));
|
||||
}
|
||||
|
||||
let nick = req.nick.trim();
|
||||
let nick_len = nick.chars().count();
|
||||
if nick_len == 0 || nick_len > 32 {
|
||||
return Err(AppError::Validation("nick must be 1 to 32 characters".into()));
|
||||
}
|
||||
if nick.chars().any(|c| c.is_control()) {
|
||||
return Err(AppError::Validation("nick must not contain control characters".into()));
|
||||
}
|
||||
|
||||
if req.password.chars().count() < 8 {
|
||||
return Err(AppError::Validation("password must be at least 8 characters".into()));
|
||||
}
|
||||
if req.password.len() > password::MAX_PASSWORD_BYTES {
|
||||
return Err(AppError::Validation(format!(
|
||||
"password must be at most {} bytes",
|
||||
password::MAX_PASSWORD_BYTES
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((email.to_string(), nick.to_string()))
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<AuthState>,
|
||||
AppJson(req): AppJson<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<RegisterResponse>), AppError> {
|
||||
let (email, nick) = validate_register(&req)?;
|
||||
let hash = state.hasher.hash(req.password.clone()).await.map_err(AppError::Internal)?;
|
||||
let account = repo::create(&state.pool, &email, &hash, &nick).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(RegisterResponse {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
display_nick: account.display_nick,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AuthState>,
|
||||
jar: CookieJar,
|
||||
AppJson(req): AppJson<LoginRequest>,
|
||||
) -> Result<(CookieJar, Json<LoginResponse>), AppError> {
|
||||
let Some(account) = repo::find_by_email(&state.pool, &req.email).await? else {
|
||||
// Burn the same Argon2 cost as a real check, then fail identically.
|
||||
state
|
||||
.hasher
|
||||
.verify(req.password.clone(), state.dummy_hash.clone())
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
return Err(AppError::Unauthorized);
|
||||
};
|
||||
if !state
|
||||
.hasher
|
||||
.verify(req.password.clone(), account.password_hash.clone())
|
||||
.await
|
||||
.map_err(AppError::Internal)?
|
||||
{
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
let refresh = tokens::store_refresh(&state.pool, account.id).await?;
|
||||
Ok((
|
||||
jar.add(tokens::refresh_cookie(refresh, state.cookie_secure)),
|
||||
Json(LoginResponse { access_token: jwt::issue_access_token(account.id, &state.jwt_secret) }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
State(state): State<AuthState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<(CookieJar, Json<LoginResponse>), AppError> {
|
||||
let token = jar.get(tokens::REFRESH_COOKIE).map(|c| c.value().to_owned()).ok_or(AppError::Unauthorized)?;
|
||||
match tokens::rotate_refresh(&state.pool, &token).await? {
|
||||
tokens::RotateOutcome::Rotated { account_id, new_token } => Ok((
|
||||
jar.add(tokens::refresh_cookie(new_token, state.cookie_secure)),
|
||||
Json(LoginResponse { access_token: jwt::issue_access_token(account_id, &state.jwt_secret) }),
|
||||
)),
|
||||
tokens::RotateOutcome::Invalid => Err(AppError::Unauthorized),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AuthState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<(CookieJar, StatusCode), AppError> {
|
||||
if let Some(cookie) = jar.get(tokens::REFRESH_COOKIE) {
|
||||
tokens::revoke_refresh(&state.pool, cookie.value()).await?;
|
||||
}
|
||||
Ok((
|
||||
jar.remove(axum_extra::extract::cookie::Cookie::build(tokens::REFRESH_COOKIE).path("/auth")),
|
||||
StatusCode::NO_CONTENT,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn valid_request() -> RegisterRequest {
|
||||
RegisterRequest {
|
||||
email: "user@example.com".into(),
|
||||
password: "password123".into(),
|
||||
nick: "Rider".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_request_passes() {
|
||||
assert!(validate_register(&valid_request()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_password_is_rejected() {
|
||||
let mut req = valid_request();
|
||||
req.password = "short12".into();
|
||||
assert!(validate_register(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_email_is_rejected() {
|
||||
let mut req = valid_request();
|
||||
req.email = " ".into();
|
||||
assert!(validate_register(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_without_at_is_rejected() {
|
||||
let mut req = valid_request();
|
||||
req.email = "not-an-email".into();
|
||||
assert!(validate_register(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_nick_is_rejected() {
|
||||
let mut req = valid_request();
|
||||
req.nick = " ".into();
|
||||
assert!(validate_register(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thirty_three_char_nick_is_rejected() {
|
||||
let mut req = valid_request();
|
||||
req.nick = "a".repeat(33);
|
||||
assert!(validate_register(&req).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,3 @@
|
|||
pub mod password;
|
||||
pub mod tokens;
|
||||
pub mod handlers;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,91 @@
|
|||
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
|
||||
use argon2::password_hash::phc::PasswordHash;
|
||||
// `as _`: the trait must be in scope for `hash_password`, but the name
|
||||
// belongs to the `PasswordHasher` struct below.
|
||||
use argon2::{Algorithm, Argon2, Params, PasswordHasher as _, PasswordVerifier, Version};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
// Explicit Argon2id parameters instead of `Argon2::default()`, so a
|
||||
// dependency bump can never silently weaken the cost (the library default
|
||||
// is the OWASP *minimum*: m=19 MiB, t=2). 64 MiB / t=3 / p=1 is
|
||||
// deliberately above that floor.
|
||||
const M_COST_KIB: u32 = 64 * 1024;
|
||||
const T_COST: u32 = 3;
|
||||
const P_COST: u32 = 1;
|
||||
|
||||
// Argon2 processes the whole input, so an unbounded password is a cheap
|
||||
// CPU/memory DoS vector on both register and login. bcrypt-style 72-byte
|
||||
// truncation is not a concern for Argon2; this is purely an abuse cap.
|
||||
pub const MAX_PASSWORD_BYTES: usize = 256;
|
||||
|
||||
fn hasher() -> Result<Argon2<'static>, argon2::password_hash::Error> {
|
||||
let params = Params::new(M_COST_KIB, T_COST, P_COST, None)
|
||||
.map_err(|_| argon2::password_hash::Error::ParamsInvalid)?;
|
||||
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
|
||||
}
|
||||
|
||||
pub fn hash_password(plain: &str) -> Result<String, argon2::password_hash::Error> {
|
||||
let argon2 = Argon2::default();
|
||||
Ok(argon2.hash_password(plain.as_bytes())?.to_string())
|
||||
if plain.len() > MAX_PASSWORD_BYTES {
|
||||
return Err(argon2::password_hash::Error::PasswordInvalid);
|
||||
}
|
||||
Ok(hasher()?.hash_password(plain.as_bytes())?.to_string())
|
||||
}
|
||||
|
||||
pub fn verify_password(plain: &str, hash: &str) -> bool {
|
||||
let Ok(parsed) = PasswordHash::new(hash) else { return false };
|
||||
if plain.len() > MAX_PASSWORD_BYTES {
|
||||
return false;
|
||||
}
|
||||
let Ok(parsed) = PasswordHash::new(hash) else {
|
||||
return false;
|
||||
};
|
||||
// Parameters are read from the stored PHC string, so hashes created
|
||||
// under older/lower settings still verify after a cost increase.
|
||||
Argon2::default()
|
||||
.verify_password(plain.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Argon2 (64 MiB, ~100ms) is CPU/memory heavy; without a cap, concurrent
|
||||
/// register/login requests could starve the tokio worker pool. Sized to the
|
||||
/// number of CPUs so hashing never oversubscribes them.
|
||||
#[derive(Clone)]
|
||||
pub struct PasswordHasher {
|
||||
permits: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl Default for PasswordHasher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordHasher {
|
||||
pub fn new() -> Self {
|
||||
let permits = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(2);
|
||||
PasswordHasher { permits: Arc::new(Semaphore::new(permits)) }
|
||||
}
|
||||
|
||||
/// Runs Argon2 hashing off the async workers, bounded by the permit count.
|
||||
pub async fn hash(&self, plain: String) -> Result<String, anyhow::Error> {
|
||||
let _permit = self.permits.acquire().await?;
|
||||
let inner = tokio::task::spawn_blocking(move || hash_password(&plain))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))??;
|
||||
Ok(inner)
|
||||
}
|
||||
|
||||
/// Runs Argon2 verification off the async workers, bounded by the permit
|
||||
/// count. Used for both real and dummy (timing-equalizer) verification.
|
||||
pub async fn verify(&self, plain: String, hash: String) -> Result<bool, anyhow::Error> {
|
||||
let _permit = self.permits.acquire().await?;
|
||||
tokio::task::spawn_blocking(move || verify_password(&plain, &hash))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -34,4 +107,36 @@ mod tests {
|
|||
let hash = hash_password("secret123").unwrap();
|
||||
assert_ne!(hash, "secret123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_uses_pinned_argon2id_params() {
|
||||
let hash = hash_password("secret123").unwrap();
|
||||
assert!(
|
||||
hash.starts_with("$argon2id$v=19$m=65536,t=3,p=1$"),
|
||||
"unexpected PHC prefix: {hash}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_password_hashes_differently_each_time() {
|
||||
let a = hash_password("secret123").unwrap();
|
||||
let b = hash_password("secret123").unwrap();
|
||||
assert_ne!(a, b, "salt must be random per hash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_length_cap_boundary() {
|
||||
let at_cap = "a".repeat(MAX_PASSWORD_BYTES);
|
||||
let hash = hash_password(&at_cap).expect("exactly the cap must be accepted");
|
||||
assert!(verify_password(&at_cap, &hash));
|
||||
|
||||
let over_cap = "a".repeat(MAX_PASSWORD_BYTES + 1);
|
||||
assert!(hash_password(&over_cap).is_err(), "cap+1 must be rejected");
|
||||
assert!(!verify_password(&over_cap, &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_hash_string_fails_verify_without_panicking() {
|
||||
assert!(!verify_password("whatever", "not-a-phc-string"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
136
backend/accounts-service/src/auth/tokens.rs
Normal file
136
backend/accounts-service/src/auth/tokens.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use rand::RngExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const REFRESH_COOKIE: &str = "lv_refresh";
|
||||
|
||||
/// 256-bit random token: nothing to brute-force, so a slow hash would only
|
||||
/// add latency to every refresh — plain SHA-256 for storage is enough.
|
||||
pub fn new_opaque_token(prefix: &str) -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill(&mut bytes);
|
||||
format!("{prefix}{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
pub fn hash_token(token: &str) -> String {
|
||||
hex::encode(Sha256::digest(token.as_bytes()))
|
||||
}
|
||||
|
||||
pub async fn store_refresh(pool: &PgPool, account_id: Uuid) -> Result<String, sqlx::Error> {
|
||||
let token = new_opaque_token("lvr_");
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_tokens (account_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, now() + interval '30 days')",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(hash_token(&token))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub enum RotateOutcome {
|
||||
Rotated { account_id: Uuid, new_token: String },
|
||||
Invalid,
|
||||
}
|
||||
|
||||
pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result<RotateOutcome, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let row: Option<(Uuid, bool, bool)> = sqlx::query_as(
|
||||
"SELECT account_id, revoked_at IS NOT NULL, expires_at <= now()
|
||||
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(hash_token(token))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let outcome = match row {
|
||||
None => RotateOutcome::Invalid,
|
||||
Some((account_id, true, _)) => {
|
||||
// Reuse of a rotated token: someone else holds a copy. Kill all sessions.
|
||||
sqlx::query(
|
||||
"UPDATE refresh_tokens SET revoked_at = now()
|
||||
WHERE account_id = $1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(account_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
RotateOutcome::Invalid
|
||||
}
|
||||
Some((_, false, true)) => RotateOutcome::Invalid,
|
||||
Some((account_id, false, false)) => {
|
||||
sqlx::query("UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1")
|
||||
.bind(hash_token(token))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let new_token = new_opaque_token("lvr_");
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_tokens (account_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, now() + interval '30 days')",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(hash_token(&new_token))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
RotateOutcome::Rotated { account_id, new_token }
|
||||
}
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub async fn revoke_refresh(pool: &PgPool, token: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(hash_token(token))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The refresh token never touches JS: httpOnly, Strict, scoped to /auth,
|
||||
/// so an XSS on the site cannot exfiltrate it.
|
||||
pub fn refresh_cookie(token: String, secure: bool) -> Cookie<'static> {
|
||||
Cookie::build((REFRESH_COOKIE, token))
|
||||
.http_only(true)
|
||||
.secure(secure)
|
||||
.same_site(SameSite::Strict)
|
||||
.path("/auth")
|
||||
.max_age(time::Duration::days(30))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn opaque_tokens_are_prefixed_unique_and_long() {
|
||||
let a = new_opaque_token("lvr_");
|
||||
let b = new_opaque_token("lvr_");
|
||||
assert!(a.starts_with("lvr_"));
|
||||
assert_eq!(a.len(), 4 + 43);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_is_stable_hex_sha256() {
|
||||
assert_eq!(hash_token("x"), hash_token("x"));
|
||||
assert_eq!(hash_token("x").len(), 64);
|
||||
assert_ne!(hash_token("x"), hash_token("y"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_cookie_has_the_hardened_attributes() {
|
||||
let cookie = refresh_cookie("t".into(), true);
|
||||
assert_eq!(cookie.name(), REFRESH_COOKIE);
|
||||
assert_eq!(cookie.http_only(), Some(true));
|
||||
assert_eq!(cookie.secure(), Some(true));
|
||||
assert_eq!(cookie.same_site(), Some(SameSite::Strict));
|
||||
assert_eq!(cookie.path(), Some("/auth"));
|
||||
}
|
||||
}
|
||||
63
backend/accounts-service/src/avatars/handlers.rs
Normal file
63
backend/accounts-service/src/avatars/handlers.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use crate::accounts::repo;
|
||||
use crate::avatars::processing::{process_avatar, AvatarImageError};
|
||||
use crate::avatars::storage::S3Storage;
|
||||
use crate::error::AppError;
|
||||
use axum::{
|
||||
extract::{Multipart, State},
|
||||
Json,
|
||||
};
|
||||
use common::internal::GatewayIdentity;
|
||||
use serde::Serialize;
|
||||
|
||||
pub const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AvatarState {
|
||||
pub pool: sqlx::PgPool,
|
||||
pub storage: S3Storage,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AvatarResponse {
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
State(state): State<AvatarState>,
|
||||
identity: GatewayIdentity,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<AvatarResponse>, AppError> {
|
||||
let account_id = identity.account_id;
|
||||
|
||||
let field = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| AppError::Validation("malformed multipart body".into()))?
|
||||
.filter(|f| f.name() == Some("file"))
|
||||
.ok_or_else(|| AppError::Validation("expected a multipart part named `file`".into()))?;
|
||||
let bytes = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|_| AppError::Validation("could not read the uploaded file".into()))?;
|
||||
if bytes.len() > MAX_UPLOAD_BYTES {
|
||||
return Err(AppError::Validation("file too large (max 5MB)".into()));
|
||||
}
|
||||
|
||||
// Decoding is CPU-bound and the input is untrusted: keep it off the async workers.
|
||||
let png = tokio::task::spawn_blocking(move || process_avatar(&bytes))
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?
|
||||
.map_err(|e| match e {
|
||||
AvatarImageError::Unsupported => {
|
||||
AppError::Validation("unsupported image format (png, jpeg, webp)".into())
|
||||
}
|
||||
AvatarImageError::Invalid => AppError::Validation("invalid or too large image".into()),
|
||||
})?;
|
||||
|
||||
let key = format!("avatars/{account_id}.png");
|
||||
state.storage.put(&key, png, "image/png").await.map_err(AppError::Internal)?;
|
||||
repo::set_avatar(&state.pool, account_id, &key).await?;
|
||||
|
||||
Ok(Json(AvatarResponse { avatar_url: format!("{}/{key}", state.base_url) }))
|
||||
}
|
||||
3
backend/accounts-service/src/avatars/mod.rs
Normal file
3
backend/accounts-service/src/avatars/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod handlers;
|
||||
pub mod processing;
|
||||
pub mod storage;
|
||||
90
backend/accounts-service/src/avatars/processing.rs
Normal file
90
backend/accounts-service/src/avatars/processing.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use image::{imageops::FilterType, ImageFormat, ImageReader, Limits};
|
||||
use std::io::Cursor;
|
||||
|
||||
pub const AVATAR_SIZE: u32 = 256;
|
||||
pub const MAX_DIMENSION: u32 = 8192;
|
||||
const MAX_DECODE_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum AvatarImageError {
|
||||
/// Not a PNG/JPEG/WebP at all.
|
||||
Unsupported,
|
||||
/// Claims to be one of those but does not decode within the limits.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// Decodes untrusted image bytes, center-crops to a square and resizes to
|
||||
/// `AVATAR_SIZE`x`AVATAR_SIZE`, then re-encodes as PNG. The output is always
|
||||
/// a freshly generated PNG — never the original bytes — so EXIF metadata and
|
||||
/// any embedded payload never reach storage.
|
||||
pub fn process_avatar(bytes: &[u8]) -> Result<Vec<u8>, AvatarImageError> {
|
||||
let mut reader = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| AvatarImageError::Invalid)?;
|
||||
if !matches!(
|
||||
reader.format(),
|
||||
Some(ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP)
|
||||
) {
|
||||
return Err(AvatarImageError::Unsupported);
|
||||
}
|
||||
let mut limits = Limits::default();
|
||||
limits.max_image_width = Some(MAX_DIMENSION);
|
||||
limits.max_image_height = Some(MAX_DIMENSION);
|
||||
limits.max_alloc = Some(MAX_DECODE_BYTES);
|
||||
reader.limits(limits);
|
||||
|
||||
let decoded = reader.decode().map_err(|_| AvatarImageError::Invalid)?;
|
||||
let square = decoded.resize_to_fill(AVATAR_SIZE, AVATAR_SIZE, FilterType::Lanczos3);
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
square
|
||||
.write_to(&mut out, ImageFormat::Png)
|
||||
.map_err(|_| AvatarImageError::Invalid)?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::ImageFormat;
|
||||
use std::io::Cursor;
|
||||
|
||||
fn png_of(width: u32, height: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::new(width, height);
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_image_becomes_a_256_square_png() {
|
||||
let out = process_avatar(&png_of(300, 100)).unwrap();
|
||||
assert!(out.starts_with(&[0x89, b'P', b'N', b'G']));
|
||||
let decoded = image::load_from_memory(&out).unwrap();
|
||||
assert_eq!((decoded.width(), decoded.height()), (AVATAR_SIZE, AVATAR_SIZE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_image_bytes_are_unsupported() {
|
||||
assert_eq!(process_avatar(b"not an image"), Err(AvatarImageError::Unsupported));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gif_is_unsupported() {
|
||||
assert_eq!(
|
||||
process_avatar(b"GIF89a\x01\x00\x01\x00\x00\x00\x00;"),
|
||||
Err(AvatarImageError::Unsupported)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_png_is_invalid() {
|
||||
let mut bytes = png_of(50, 50);
|
||||
bytes.truncate(40);
|
||||
assert_eq!(process_avatar(&bytes), Err(AvatarImageError::Invalid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_wider_than_the_limit_is_rejected() {
|
||||
assert_eq!(process_avatar(&png_of(MAX_DIMENSION + 1, 1)), Err(AvatarImageError::Invalid));
|
||||
}
|
||||
}
|
||||
36
backend/accounts-service/src/avatars/storage.rs
Normal file
36
backend/accounts-service/src/avatars/storage.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct S3Storage {
|
||||
client: Client,
|
||||
bucket: String,
|
||||
}
|
||||
|
||||
impl S3Storage {
|
||||
/// Builds the S3 client synchronously (no I/O happens here — connections
|
||||
/// are made lazily on first request).
|
||||
pub fn from_config(endpoint: &str, access_key: &str, secret_key: &str, bucket: String) -> Self {
|
||||
let creds = aws_sdk_s3::config::Credentials::new(access_key, secret_key, None, None, "static");
|
||||
let config = aws_sdk_s3::config::Builder::new()
|
||||
.endpoint_url(endpoint)
|
||||
.credentials_provider(creds)
|
||||
.region(aws_sdk_s3::config::Region::new("us-east-1"))
|
||||
.force_path_style(true)
|
||||
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||
.build();
|
||||
S3Storage { client: Client::from_conf(config), bucket }
|
||||
}
|
||||
|
||||
pub async fn put(&self, key: &str, bytes: Vec<u8>, content_type: &str) -> anyhow::Result<()> {
|
||||
self.client
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(bytes))
|
||||
.content_type(content_type)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,13 @@ use anyhow::{Context, Result};
|
|||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub jwt_secret: String,
|
||||
pub internal_key: String,
|
||||
pub port: u16,
|
||||
pub s3_endpoint: String,
|
||||
pub s3_bucket: String,
|
||||
pub s3_access_key: String,
|
||||
pub s3_secret_key: String,
|
||||
pub cookie_secure: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -18,6 +20,8 @@ impl Config {
|
|||
.context("DATABASE_URL not set")?,
|
||||
jwt_secret: std::env::var("JWT_SECRET")
|
||||
.context("JWT_SECRET not set")?,
|
||||
internal_key: std::env::var("INTERNAL_KEY")
|
||||
.context("INTERNAL_KEY not set")?,
|
||||
port: std::env::var("PORT")
|
||||
.unwrap_or_else(|_| "8081".into())
|
||||
.parse()
|
||||
|
|
@ -30,6 +34,72 @@ impl Config {
|
|||
.context("S3_ACCESS_KEY not set")?,
|
||||
s3_secret_key: std::env::var("S3_SECRET_KEY")
|
||||
.context("S3_SECRET_KEY not set")?,
|
||||
cookie_secure: std::env::var("COOKIE_SECURE").map(|v| v != "false").unwrap_or(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_JWT_SECRET_BYTES: usize = 32;
|
||||
|
||||
impl Config {
|
||||
/// Fail fast at startup on a secret too weak to sign HS256 tokens with
|
||||
/// (the `.env.example` placeholder must never reach production).
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.jwt_secret.len() < MIN_JWT_SECRET_BYTES {
|
||||
anyhow::bail!("JWT_SECRET must be at least {MIN_JWT_SECRET_BYTES} bytes");
|
||||
}
|
||||
if self.internal_key.len() < MIN_JWT_SECRET_BYTES {
|
||||
anyhow::bail!("INTERNAL_KEY must be at least {MIN_JWT_SECRET_BYTES} bytes");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Public prefix of stored avatars: `<endpoint>/<bucket>`.
|
||||
pub fn avatar_base_url(&self) -> String {
|
||||
format!("{}/{}", self.s3_endpoint.trim_end_matches('/'), self.s3_bucket)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config_with_secret(secret: &str) -> Config {
|
||||
Config {
|
||||
database_url: String::new(),
|
||||
jwt_secret: secret.into(),
|
||||
internal_key: "k".repeat(32),
|
||||
port: 0,
|
||||
s3_endpoint: String::new(),
|
||||
s3_bucket: String::new(),
|
||||
s3_access_key: String::new(),
|
||||
s3_secret_key: String::new(),
|
||||
cookie_secure: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_internal_key_is_rejected() {
|
||||
let mut cfg = config_with_secret(&"x".repeat(32));
|
||||
cfg.internal_key = "short".into();
|
||||
assert!(cfg.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_jwt_secret_is_rejected() {
|
||||
assert!(config_with_secret("too-short").validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_jwt_secret_is_accepted() {
|
||||
assert!(config_with_secret(&"x".repeat(32)).validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avatar_base_url_joins_endpoint_and_bucket_without_double_slash() {
|
||||
let mut cfg = config_with_secret(&"x".repeat(32));
|
||||
cfg.s3_endpoint = "http://localhost:9000/".into();
|
||||
cfg.s3_bucket = "avatars".into();
|
||||
assert_eq!(cfg.avatar_base_url(), "http://localhost:9000/avatars");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
backend/accounts-service/src/device/handlers.rs
Normal file
73
backend/accounts-service/src/device/handlers.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use crate::device::store::{self, DeviceStore, PollResult};
|
||||
use crate::error::{AppError, AppJson};
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use common::internal::GatewayIdentity;
|
||||
use common::jwt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceState {
|
||||
pub store: DeviceStore,
|
||||
pub jwt_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DeviceCodeResponse {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
pub async fn create_code(
|
||||
State(state): State<DeviceState>,
|
||||
) -> Result<(StatusCode, Json<DeviceCodeResponse>), AppError> {
|
||||
let (device_code, user_code) = state.store.create().ok_or(AppError::TooManyRequests)?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(DeviceCodeResponse { device_code, user_code, expires_in: store::TTL.as_secs() }),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConfirmRequest {
|
||||
pub user_code: String,
|
||||
}
|
||||
|
||||
pub async fn confirm(
|
||||
State(state): State<DeviceState>,
|
||||
identity: GatewayIdentity,
|
||||
AppJson(req): AppJson<ConfirmRequest>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
if state.store.confirm(&req.user_code, identity.account_id) {
|
||||
Ok(StatusCode::OK)
|
||||
} else {
|
||||
Err(AppError::NotFound("unknown or expired user_code".into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TokenRequest {
|
||||
pub device_code: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TokenResponse {
|
||||
pub device_token: String,
|
||||
}
|
||||
|
||||
pub async fn token(
|
||||
State(state): State<DeviceState>,
|
||||
AppJson(req): AppJson<TokenRequest>,
|
||||
) -> Result<(StatusCode, Json<Option<TokenResponse>>), AppError> {
|
||||
match state.store.poll(&req.device_code) {
|
||||
PollResult::Unknown => Err(AppError::NotFound("unknown or expired device_code".into())),
|
||||
PollResult::Pending => Ok((StatusCode::ACCEPTED, Json(None))),
|
||||
PollResult::Confirmed(account_id) => {
|
||||
// The long-lived device_token is just a refresh-style JWT for now;
|
||||
// the gateway plan is where per-device revocation via
|
||||
// device_links.device_token_hash gets enforced on every request.
|
||||
let device_token = jwt::issue_refresh_token(account_id, &state.jwt_secret);
|
||||
Ok((StatusCode::OK, Json(Some(TokenResponse { device_token }))))
|
||||
}
|
||||
}
|
||||
}
|
||||
2
backend/accounts-service/src/device/mod.rs
Normal file
2
backend/accounts-service/src/device/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod handlers;
|
||||
pub mod store;
|
||||
192
backend/accounts-service/src/device/store.rs
Normal file
192
backend/accounts-service/src/device/store.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceCodeEntry {
|
||||
pub user_code: String,
|
||||
pub confirmed_account_id: Option<Uuid>,
|
||||
pub expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DeviceStore {
|
||||
by_device_code: Arc<DashMap<String, DeviceCodeEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PollResult {
|
||||
Unknown,
|
||||
Pending,
|
||||
Confirmed(Uuid),
|
||||
}
|
||||
|
||||
pub(crate) const TTL: Duration = Duration::from_secs(600);
|
||||
// /device/code is unauthenticated, so the store must be bounded or anyone can
|
||||
// grow process memory without limit. Expired entries are purged on every
|
||||
// create, so the cap only bites under sustained abuse.
|
||||
const MAX_PENDING: usize = 10_000;
|
||||
|
||||
fn random_user_code() -> String {
|
||||
use rand::RngExt;
|
||||
const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no O/0/I/1 confusion
|
||||
let mut rng = rand::rng();
|
||||
let mut part = |n: usize| -> String {
|
||||
(0..n).map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char).collect()
|
||||
};
|
||||
format!("{}-{}", part(4), part(4))
|
||||
}
|
||||
|
||||
impl DeviceStore {
|
||||
/// Returns `(device_code, user_code)`, or `None` when the store is full.
|
||||
pub fn create(&self) -> Option<(String, String)> {
|
||||
let now = Instant::now();
|
||||
self.by_device_code.retain(|_, entry| entry.expires_at > now);
|
||||
if self.by_device_code.len() >= MAX_PENDING {
|
||||
return None;
|
||||
}
|
||||
let device_code = Uuid::new_v4().to_string();
|
||||
let user_code = random_user_code();
|
||||
self.by_device_code.insert(
|
||||
device_code.clone(),
|
||||
DeviceCodeEntry {
|
||||
user_code: user_code.clone(),
|
||||
confirmed_account_id: None,
|
||||
expires_at: now + TTL,
|
||||
},
|
||||
);
|
||||
Some((device_code, user_code))
|
||||
}
|
||||
|
||||
/// Returns true if a matching, unexpired entry was found and confirmed.
|
||||
/// The user types this code by hand, so it is trimmed and upper-cased.
|
||||
pub fn confirm(&self, user_code: &str, account_id: Uuid) -> bool {
|
||||
let wanted = user_code.trim().to_uppercase();
|
||||
let now = Instant::now();
|
||||
for mut entry in self.by_device_code.iter_mut() {
|
||||
// First confirm wins: an already-confirmed code cannot be
|
||||
// re-bound to a different account before the device collects it.
|
||||
if entry.user_code == wanted
|
||||
&& entry.expires_at > now
|
||||
&& entry.confirmed_account_id.is_none()
|
||||
{
|
||||
entry.confirmed_account_id = Some(account_id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A confirmed entry is CONSUMED by the first poll that sees it: the
|
||||
/// token can be collected exactly once, replaying the same device_code
|
||||
/// afterwards yields `Unknown`. Expired entries are dropped and Unknown.
|
||||
pub fn poll(&self, device_code: &str) -> PollResult {
|
||||
let now = Instant::now();
|
||||
let removed = self.by_device_code.remove_if(device_code, |_, entry| {
|
||||
entry.confirmed_account_id.is_some() || entry.expires_at <= now
|
||||
});
|
||||
if let Some((_, entry)) = removed {
|
||||
return match entry.confirmed_account_id {
|
||||
Some(id) if entry.expires_at > now => PollResult::Confirmed(id),
|
||||
_ => PollResult::Unknown,
|
||||
};
|
||||
}
|
||||
if self.by_device_code.contains_key(device_code) {
|
||||
PollResult::Pending
|
||||
} else {
|
||||
PollResult::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn create_returns_distinct_codes() {
|
||||
let store = DeviceStore::default();
|
||||
let (dc1, uc1) = store.create().unwrap();
|
||||
let (dc2, uc2) = store.create().unwrap();
|
||||
assert_ne!(dc1, dc2);
|
||||
assert_ne!(uc1, uc2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_then_poll_returns_account_id() {
|
||||
let store = DeviceStore::default();
|
||||
let (device_code, user_code) = store.create().unwrap();
|
||||
let account_id = Uuid::new_v4();
|
||||
|
||||
assert!(store.confirm(&user_code, account_id));
|
||||
assert_eq!(store.poll(&device_code), PollResult::Confirmed(account_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_code_can_only_be_collected_once() {
|
||||
let store = DeviceStore::default();
|
||||
let (device_code, user_code) = store.create().unwrap();
|
||||
assert!(store.confirm(&user_code, Uuid::new_v4()));
|
||||
|
||||
assert!(matches!(store.poll(&device_code), PollResult::Confirmed(_)));
|
||||
assert_eq!(store.poll(&device_code), PollResult::Unknown, "replay must fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_before_confirm_is_pending_and_repeatable() {
|
||||
let store = DeviceStore::default();
|
||||
let (device_code, _user_code) = store.create().unwrap();
|
||||
assert_eq!(store.poll(&device_code), PollResult::Pending);
|
||||
assert_eq!(store.poll(&device_code), PollResult::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_unknown_code_is_unknown() {
|
||||
let store = DeviceStore::default();
|
||||
assert_eq!(store.poll("does-not-exist"), PollResult::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_unknown_user_code_returns_false() {
|
||||
let store = DeviceStore::default();
|
||||
assert!(!store.confirm("ZZZZ-ZZZZ", Uuid::new_v4()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_confirm_cannot_overwrite_the_first_account() {
|
||||
let store = DeviceStore::default();
|
||||
let (device_code, user_code) = store.create().unwrap();
|
||||
let first = Uuid::new_v4();
|
||||
let attacker = Uuid::new_v4();
|
||||
|
||||
assert!(store.confirm(&user_code, first));
|
||||
assert!(!store.confirm(&user_code, attacker), "re-confirm must be refused");
|
||||
assert_eq!(store.poll(&device_code), PollResult::Confirmed(first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_accepts_lowercase_and_surrounding_whitespace() {
|
||||
let store = DeviceStore::default();
|
||||
let (device_code, user_code) = store.create().unwrap();
|
||||
let typed = format!(" {} ", user_code.to_lowercase());
|
||||
assert!(store.confirm(&typed, Uuid::new_v4()));
|
||||
assert!(matches!(store.poll(&device_code), PollResult::Confirmed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_entries_are_purged_and_store_is_bounded() {
|
||||
let store = DeviceStore::default();
|
||||
for _ in 0..MAX_PENDING {
|
||||
assert!(store.create().is_some());
|
||||
}
|
||||
assert!(store.create().is_none(), "store must refuse beyond MAX_PENDING");
|
||||
|
||||
// Force everything to be expired; the next create purges and succeeds.
|
||||
for mut entry in store.by_device_code.iter_mut() {
|
||||
entry.expires_at = Instant::now() - Duration::from_secs(1);
|
||||
}
|
||||
assert!(store.create().is_some());
|
||||
assert_eq!(store.by_device_code.len(), 1);
|
||||
}
|
||||
}
|
||||
66
backend/accounts-service/src/error.rs
Normal file
66
backend/accounts-service/src/error.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use axum::{
|
||||
extract::{rejection::JsonRejection, FromRequest},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
Validation(String),
|
||||
Conflict(String),
|
||||
Unauthorized,
|
||||
NotFound(String),
|
||||
TooManyRequests,
|
||||
Internal(anyhow::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg),
|
||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||
AppError::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too many pending device codes".into()),
|
||||
AppError::Internal(err) => {
|
||||
tracing::error!("internal error: {err:?}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
if let sqlx::Error::Database(ref db_err) = err
|
||||
&& db_err.constraint() == Some("accounts_email_lower_idx")
|
||||
{
|
||||
return AppError::Conflict("email already registered".into());
|
||||
}
|
||||
AppError::Internal(err.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JsonRejection> for AppError {
|
||||
fn from(rejection: JsonRejection) -> Self {
|
||||
AppError::Validation(rejection.body_text())
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop-in replacement for `axum::Json` that reports malformed/invalid JSON
|
||||
/// bodies as our `{"error": ...}` shape instead of axum's plain-text default.
|
||||
#[derive(FromRequest)]
|
||||
#[from_request(via(axum::Json), rejection(AppError))]
|
||||
pub struct AppJson<T>(pub T);
|
||||
|
||||
impl<T> IntoResponse for AppJson<T>
|
||||
where
|
||||
Json<T>: IntoResponse,
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
Json(self.0).into_response()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,68 @@
|
|||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod avatars;
|
||||
pub mod config;
|
||||
pub mod device;
|
||||
pub mod error;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use auth::handlers::AuthState;
|
||||
use avatars::{handlers::AvatarState, storage::S3Storage};
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use config::Config;
|
||||
use device::{handlers::DeviceState, store::DeviceStore};
|
||||
|
||||
pub fn build_app(_pool: sqlx::PgPool) -> Router {
|
||||
Router::new().route("/health", get(|| async { "ok" }))
|
||||
pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
|
||||
let auth_state =
|
||||
AuthState::new(pool.clone(), cfg.jwt_secret.clone(), cfg.cookie_secure);
|
||||
let device_state =
|
||||
DeviceState { store: DeviceStore::default(), jwt_secret: cfg.jwt_secret.clone() };
|
||||
let avatar_state = AvatarState {
|
||||
pool: pool.clone(),
|
||||
storage: S3Storage::from_config(
|
||||
&cfg.s3_endpoint,
|
||||
&cfg.s3_access_key,
|
||||
&cfg.s3_secret_key,
|
||||
cfg.s3_bucket.clone(),
|
||||
),
|
||||
base_url: cfg.avatar_base_url(),
|
||||
};
|
||||
|
||||
let auth_routes = Router::new()
|
||||
.route("/auth/register", post(auth::handlers::register))
|
||||
.route("/auth/login", post(auth::handlers::login))
|
||||
.route("/auth/refresh", post(auth::handlers::refresh))
|
||||
.route("/auth/logout", post(auth::handlers::logout))
|
||||
.with_state(auth_state);
|
||||
|
||||
let device_routes = Router::new()
|
||||
.route("/device/code", post(device::handlers::create_code))
|
||||
.route("/device/confirm", post(device::handlers::confirm))
|
||||
.route("/device/token", post(device::handlers::token))
|
||||
.with_state(device_state);
|
||||
|
||||
let avatar_routes = Router::new()
|
||||
.route("/avatars", post(avatars::handlers::upload))
|
||||
// Hard transport cap slightly above the 5 MB business limit (413 beyond it).
|
||||
.layer(DefaultBodyLimit::max(6 * 1024 * 1024))
|
||||
.with_state(avatar_state);
|
||||
|
||||
// Everything except /health is internal-only: reachable solely through
|
||||
// the gateway, which authenticates the caller and forwards the identity
|
||||
// header. Direct traffic (or spoofed headers) is rejected here.
|
||||
let api = Router::new()
|
||||
.merge(auth_routes)
|
||||
.merge(device_routes)
|
||||
.merge(avatar_routes)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
common::internal::InternalKey::new(cfg.internal_key.clone()),
|
||||
common::internal::require_internal_key,
|
||||
));
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.merge(api)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ async fn main() -> anyhow::Result<()> {
|
|||
tracing_subscriber::fmt::init();
|
||||
dotenvy::dotenv().ok();
|
||||
let cfg = Config::from_env()?;
|
||||
cfg.validate()?;
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(&cfg.database_url)
|
||||
.await?;
|
||||
|
||||
let app = build_app(pool);
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
let app = build_app(pool, &cfg);
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
|
||||
tracing::info!("accounts-service listening on {}", cfg.port);
|
||||
axum::serve(listener, app).await?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue