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:
loki5512344 2026-09-24 23:52:12 +02:00
parent 9d08fa910a
commit 72bc4c7148
1897 changed files with 36199 additions and 39289 deletions

View file

@ -1,7 +1,12 @@
DATABASE_URL=postgres://lovisual:lovisual@localhost:5432/accounts_db
JWT_SECRET=change-me-to-a-long-random-string
# at least 32 random bytes, e.g. `openssl rand -hex 32`
JWT_SECRET=
PORT=8081
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=lovisual-avatars
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
# Shared secret between gateway and internal services (openssl rand -hex 32)
INTERNAL_KEY=
# Secure cookie flag: leave unset (or true) in production (HTTPS); false for local HTTP dev
COOKIE_SECURE=false

495
backend/Cargo.lock generated
View file

@ -11,15 +11,21 @@ dependencies = [
"aws-config",
"aws-sdk-s3",
"axum",
"axum-extra",
"axum-test",
"base64",
"chrono",
"common",
"dashmap",
"dotenvy",
"jsonwebtoken",
"hex",
"image",
"rand 0.10.3",
"serde",
"serde_json",
"sha2 0.11.0",
"sqlx",
"time",
"tokio",
"tower-http",
"tracing",
@ -27,6 +33,12 @@ dependencies = [
"uuid",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.5"
@ -78,6 +90,17 @@ dependencies = [
"password-hash",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "atoi"
version = "2.0.0"
@ -581,6 +604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"axum-macros",
"bytes",
"form_urlencoded",
"futures-util",
@ -627,6 +651,39 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970"
dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"futures-core",
"futures-util",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"mime",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "axum-test"
version = "21.1.0"
@ -726,12 +783,24 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@ -812,6 +881,25 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "common"
version = "0.1.0"
dependencies = [
"axum",
"axum-test",
"chrono",
"jsonwebtoken",
"prost",
"serde",
"serde_json",
"subtle",
"tokio",
"tonic",
"tonic-prost",
"tonic-prost-build",
"uuid",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@ -830,6 +918,7 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
@ -1280,6 +1369,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.13.1"
@ -1302,6 +1400,23 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]]
name = "flume"
version = "0.12.0"
@ -1319,6 +1434,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@ -1500,6 +1621,15 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@ -1508,7 +1638,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@ -1517,7 +1647,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@ -1730,6 +1860,19 @@ dependencies = [
"tower-service",
]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper 1.11.1",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@ -1881,6 +2024,32 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.2"
@ -1906,6 +2075,15 @@ version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@ -1986,6 +2164,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
@ -2044,6 +2228,26 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.3"
@ -2055,6 +2259,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@ -2072,6 +2286,12 @@ dependencies = [
"version_check",
]
[[package]]
name = "multimap"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "multiversion"
version = "0.9.0"
@ -2299,6 +2519,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phc"
version = "0.6.1"
@ -2310,6 +2541,26 @@ dependencies = [
"getrandom 0.4.3",
]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@ -2349,6 +2600,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
@ -2383,6 +2647,16 @@ dependencies = [
"yansi",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "primeorder"
version = "0.13.6"
@ -2401,6 +2675,91 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prost"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [
"heck",
"itertools",
"log",
"multimap",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"pulldown-cmark",
"pulldown-cmark-to-cmark",
"regex",
"syn 2.0.119",
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "prost-types"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
dependencies = [
"prost",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags",
"memchr",
"unicase",
]
[[package]]
name = "pulldown-cmark-to-cmark"
version = "22.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.47"
@ -2584,6 +2943,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.21.12"
@ -2879,6 +3251,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simdutf8"
version = "0.1.5"
@ -3188,6 +3566,19 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "2.0.21"
@ -3335,6 +3726,74 @@ dependencies = [
"tokio",
]
[[package]]
name = "tonic"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-trait",
"axum",
"base64",
"bytes",
"h2 0.4.19",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"hyper 1.11.1",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"socket2 0.6.5",
"sync_wrapper",
"tokio",
"tokio-stream",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tonic-build"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322"
dependencies = [
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tonic-prost"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
"prost",
"tonic",
]
[[package]]
name = "tonic-prost-build"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build",
"prost-types",
"quote",
"syn 2.0.119",
"tempfile",
"tonic-build",
]
[[package]]
name = "tower"
version = "0.5.3"
@ -3343,9 +3802,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"indexmap",
"pin-project-lite",
"slab",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
@ -3480,6 +3942,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@ -3924,8 +4392,29 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]

View file

@ -2,6 +2,7 @@
resolver = "2"
members = [
"accounts-service",
"common",
]
# Planned members, added when their own implementation plan starts

File diff suppressed because it is too large Load diff

View file

@ -9,10 +9,10 @@ backend/
Cargo.toml # workspace root — members растёт по мере реализации
STRUCTURE.md # этот файл
accounts-service/ # РЕАЛИЗУЕТСЯ — backend/PLAN.md (Подсистема 1, часть 1)
gateway/ # ПЛАН НЕ НАПИСАН — routing + единая точка JWT-проверки
gateway/ # ПЛАН: gateway/PLAN.md — routing + единая точка JWT-проверки
# + рейт-лимиты (TODO.md §6). Начинается после
# accounts-service, т.к. фронтит уже готовый сервис.
configs-service/ # ПЛАН НЕ НАПИСАН — 4 слота конфигов, share-коды,
configs-service/ # ПЛАН: configs-service/PLAN.md — 4 слота конфигов, share-коды,
# витрина (Подсистема 1, часть 2)
chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence,
# виджеты-телеметрия (Подсистема 2). Единственный

View file

@ -8,7 +8,7 @@ name = "accounts_service"
path = "src/lib.rs"
[dependencies]
axum = { version = "0.8", features = ["multipart"] }
axum = { version = "0.8", features = ["multipart", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tower-http = { version = "0.7", features = ["trace", "cors"] }
tracing = "0.1"
@ -19,11 +19,17 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio",
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
argon2 = "0.6"
jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto"] }
axum-extra = { version = "0.12", features = ["cookie"] }
time = "0.3"
sha2 = "0.11"
hex = "0.4"
base64 = "0.22"
common = { path = "../common" }
rand = "0.10"
dashmap = "6"
aws-sdk-s3 = "1"
aws-config = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
anyhow = "1"
dotenvy = "0.15"

View file

@ -1,8 +1,21 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Hardening: revoke implicit PUBLIC access to this schema. This Postgres
-- instance hosts multiple services' databases (accounts_db/configs_db/
-- chat_db per TODO.md Фаза 10) on one cluster; the app role connects as
-- its own owner and needs no PUBLIC grant to function.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
-- No plain UNIQUE on email: Postgres TEXT comparison is case-sensitive,
-- so "User@x.com" and "user@x.com" would be treated as different rows,
-- letting one person hold two accounts under what looks like the same
-- email (account-confusion / signup-limit-bypass class of bug). A
-- unique index on lower(email) enforces uniqueness case-insensitively;
-- application code (accounts::repo) must look up/insert by
-- `email.to_lowercase()` for this index to actually get hit.
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
display_nick TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
@ -10,6 +23,8 @@ CREATE TABLE accounts (
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX accounts_email_lower_idx ON accounts (lower(email));
CREATE TABLE avatars (
account_id UUID PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
s3_key TEXT NOT NULL,

View file

@ -0,0 +1,12 @@
-- Opaque refresh tokens (stored hashed). Rotated on every use; presenting an
-- already-rotated token revokes the whole account's sessions (theft signal).
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_refresh_tokens_account_id ON refresh_tokens(account_id);

View file

@ -0,0 +1,2 @@
pub mod model;
pub mod repo;

View 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>,
}

View 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();
}
}

View 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());
}
}

View file

@ -1 +1,3 @@
pub mod password;
pub mod tokens;
pub mod handlers;

View file

@ -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"));
}
}

View 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"));
}
}

View 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) }))
}

View file

@ -0,0 +1,3 @@
pub mod handlers;
pub mod processing;
pub mod storage;

View 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));
}
}

View 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(())
}
}

View file

@ -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");
}
}

View 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 }))))
}
}
}

View file

@ -0,0 +1,2 @@
pub mod handlers;
pub mod store;

View 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);
}
}

View 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()
}
}

View file

@ -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)
}

View file

@ -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?;

View file

@ -0,0 +1,162 @@
use serde_json::json;
use uuid::Uuid;
mod common;
// A test root's submodules resolve against `tests/`, not the file's own
// directory — pin the path so `tests/` itself stays at 4 files.
#[path = "auth_flow/refresh.rs"]
mod refresh;
#[tokio::test]
async fn register_then_login_succeeds() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let email = format!("flow-{}@example.com", Uuid::new_v4());
let register_response = server
.post("/auth/register")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple", "nick": "Rider" }))
.await;
register_response.assert_status(axum::http::StatusCode::CREATED);
let login_response = server
.post("/auth/login")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple" }))
.await;
login_response.assert_status_ok();
let body: serde_json::Value = login_response.json();
assert!(body["access_token"].is_string());
assert!(body["refresh_token"].is_null(), "refresh token must be in the httpOnly cookie, not the body");
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn login_with_wrong_password_returns_401() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let email = format!("wrongpw-{}@example.com", Uuid::new_v4());
server
.post("/auth/register")
.json(&json!({ "email": email, "password": "right-password", "nick": "Rider" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
let login_response = server
.post("/auth/login")
.json(&json!({ "email": email, "password": "wrong-password" }))
.await;
login_response.assert_status(axum::http::StatusCode::UNAUTHORIZED);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn duplicate_email_registration_returns_409() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let email = format!("dup-{}@example.com", Uuid::new_v4());
server
.post("/auth/register")
.json(&json!({ "email": email, "password": "password123", "nick": "First" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
let second = server
.post("/auth/register")
.json(&json!({ "email": email, "password": "password456", "nick": "Second" }))
.await;
second.assert_status(axum::http::StatusCode::CONFLICT);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn login_with_unknown_email_returns_401() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let response = server
.post("/auth/login")
.json(&json!({ "email": format!("nobody-{}@example.com", Uuid::new_v4()), "password": "whatever-123" }))
.await;
response.assert_status(axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn login_is_case_insensitive_on_email() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let tag = Uuid::new_v4();
let registered = format!("CaseUser-{tag}@Example.com");
server
.post("/auth/register")
.json(&json!({ "email": registered, "password": "password123", "nick": "Rider" }))
.await
.assert_status(axum::http::StatusCode::CREATED);
server
.post("/auth/login")
.json(&json!({ "email": registered.to_lowercase(), "password": "password123" }))
.await
.assert_status_ok();
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(registered.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn malformed_json_body_returns_400_with_json_error_shape() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let response = server
.post("/auth/register")
.content_type("application/json")
.bytes(axum::body::Bytes::from_static(b"{ this is not valid json"))
.await;
response.assert_status(axum::http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = response.json();
assert!(body["error"].is_string(), "expected {{\"error\": ...}}, got {body}");
}
#[tokio::test]
async fn register_rejects_oversized_password_with_400() {
let pool = common::test_pool().await;
let app = accounts_service::build_app(pool.clone(), &common::test_config());
let server = common::test_server(app);
let response = server
.post("/auth/register")
.json(&json!({
"email": format!("big-{}@example.com", Uuid::new_v4()),
"password": "a".repeat(10_000),
"nick": "Rider"
}))
.await;
response.assert_status(axum::http::StatusCode::BAD_REQUEST);
}

View file

@ -0,0 +1,92 @@
use super::common;
use axum::http::StatusCode;
use axum_extra::extract::cookie::Cookie;
use serde_json::json;
async fn login(server: &axum_test::TestServer, email: &str) -> (String, String) {
let res = server
.post("/auth/login")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple" }))
.await;
res.assert_status_ok();
let cookie = res.cookie("lv_refresh");
assert!(cookie.http_only().unwrap_or(false));
assert_eq!(cookie.path(), Some("/auth"));
let body: serde_json::Value = res.json();
assert!(body.get("refresh_token").is_none(), "refresh token must not be in the JSON body");
(body["access_token"].as_str().unwrap().to_owned(), cookie.value().to_owned())
}
#[tokio::test]
async fn refresh_rotates_the_cookie_and_issues_a_new_access_token() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (_, email) = common::register_account(&server).await;
let (_, refresh) = login(&server, &email).await;
let res = server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", refresh.clone()))
.await;
res.assert_status_ok();
assert!(res.json::<serde_json::Value>()["access_token"].is_string());
assert_ne!(res.cookie("lv_refresh").value(), refresh);
}
#[tokio::test]
async fn reusing_a_rotated_refresh_token_revokes_all_sessions() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (_, email) = common::register_account(&server).await;
let (_, first) = login(&server, &email).await;
let second = server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", first.clone()))
.await
.cookie("lv_refresh")
.value()
.to_owned();
// Attacker replays the old token -> rejected, and the legit new one dies too.
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", first))
.await
.assert_status(StatusCode::UNAUTHORIZED);
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", second))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn logout_revokes_the_refresh_token() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (_, email) = common::register_account(&server).await;
let (_, refresh) = login(&server, &email).await;
server
.post("/auth/logout")
.add_cookie(Cookie::new("lv_refresh", refresh.clone()))
.await
.assert_status(StatusCode::NO_CONTENT);
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", refresh))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn refresh_without_cookie_or_with_garbage_is_401() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
server.post("/auth/refresh").await.assert_status(StatusCode::UNAUTHORIZED);
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", "lvr_garbage"))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}

View file

@ -0,0 +1,100 @@
mod common;
use axum::http::StatusCode;
use axum_test::multipart::{MultipartForm, Part};
use common::ACCOUNT_ID_HEADER;
fn png_bytes() -> Vec<u8> {
let img = image::RgbaImage::new(64, 32);
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
fn form(bytes: Vec<u8>) -> MultipartForm {
MultipartForm::new().add_part("file", Part::bytes(bytes).file_name("a.png").mime_type("image/png"))
}
#[tokio::test]
async fn valid_upload_stores_avatar_and_returns_url() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let (account_id, email) = common::register_account(&server).await;
let response = server
.post("/avatars")
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
.multipart(form(png_bytes()))
.await;
response.assert_status_ok();
let url = response.json::<serde_json::Value>()["avatar_url"].as_str().unwrap().to_string();
assert!(url.starts_with("http://localhost:9000/lovisual-avatars-test/avatars/"), "{url}");
assert!(url.ends_with(".png"), "{url}");
let stored: (String,) = sqlx::query_as(
"SELECT a.s3_key FROM avatars a JOIN accounts c ON c.id = a.account_id WHERE lower(c.email) = $1",
)
.bind(email.to_lowercase())
.fetch_one(&pool)
.await
.expect("avatars row must exist");
assert!(url.ends_with(&stored.0));
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(email.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn non_image_upload_is_rejected_with_400() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let (account_id, email) = common::register_account(&server).await;
server
.post("/avatars")
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
.multipart(form(b"definitely not an image".to_vec()))
.await
.assert_status(StatusCode::BAD_REQUEST);
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(email.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn oversized_upload_is_rejected_with_400() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let (account_id, email) = common::register_account(&server).await;
server
.post("/avatars")
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
.multipart(form(vec![0u8; 5 * 1024 * 1024 + 1]))
.await
.assert_status(StatusCode::BAD_REQUEST);
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
.bind(email.to_lowercase())
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn upload_without_identity_is_401() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
server
.post("/avatars")
.multipart(form(png_bytes()))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}

View file

@ -0,0 +1,52 @@
#![allow(dead_code, unused_imports)] // each test binary uses a different subset
use accounts_service::config::Config;
use axum_test::TestServer;
use uuid::Uuid;
// `::` — the workspace crate, not this module (which every test binary
// mounts as `mod common;`).
pub use ::common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
pub async fn test_pool() -> sqlx::PgPool {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
let pool = sqlx::PgPool::connect(&url).await.expect("connect to test database");
sqlx::migrate!("./migrations").run(&pool).await.expect("run migrations");
pool
}
pub fn test_config() -> Config {
Config {
database_url: String::new(),
jwt_secret: "test-secret-test-secret-test-secret!".into(),
internal_key: TEST_INTERNAL_KEY.into(),
port: 0,
s3_endpoint: "http://localhost:9000".into(),
s3_bucket: "lovisual-avatars-test".into(),
s3_access_key: "minioadmin".into(),
s3_secret_key: "minioadmin".into(),
cookie_secure: false,
}
}
pub const TEST_INTERNAL_KEY: &str = "internal-key-internal-key-internal!!";
/// A server that behaves like it sits behind the gateway.
pub fn test_server(app: axum::Router) -> TestServer {
let mut server = TestServer::new(app);
server.add_header(INTERNAL_KEY_HEADER, TEST_INTERNAL_KEY);
server
}
/// Registers a fresh random account; returns its id and email.
pub async fn register_account(server: &TestServer) -> (Uuid, String) {
let email = format!("t-{}@example.com", Uuid::new_v4());
let res = server
.post("/auth/register")
.json(&serde_json::json!({ "email": email, "password": "correct-horse-battery-staple", "nick": "Tester" }))
.await;
res.assert_status(axum::http::StatusCode::CREATED);
let body: serde_json::Value = res.json();
(Uuid::parse_str(body["id"].as_str().unwrap()).unwrap(), email)
}

View file

@ -1,14 +0,0 @@
#[tokio::test]
async fn migrations_create_accounts_table() {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
let pool = sqlx::PgPool::connect(&database_url).await.expect("connect");
sqlx::migrate!("./migrations").run(&pool).await.expect("migrate");
let row: (i64,) = sqlx::query_as("SELECT count(*) FROM accounts")
.fetch_one(&pool)
.await
.expect("accounts table must exist");
assert_eq!(row.0, 0);
}

View file

@ -0,0 +1,87 @@
mod common;
use axum::http::StatusCode;
use serde_json::json;
#[tokio::test]
async fn full_device_link_flow() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let (account_id, email) = common::register_account(&server).await;
let code_response: serde_json::Value = server.post("/device/code").await.json();
let device_code = code_response["device_code"].as_str().unwrap();
let user_code = code_response["user_code"].as_str().unwrap();
let poll_before = server
.post("/device/token")
.json(&json!({ "device_code": device_code }))
.await;
poll_before.assert_status(StatusCode::ACCEPTED);
server
.post("/device/confirm")
.add_header(common::ACCOUNT_ID_HEADER, account_id.to_string())
.json(&json!({ "user_code": user_code }))
.await
.assert_status_ok();
let poll_after = server
.post("/device/token")
.json(&json!({ "device_code": device_code }))
.await;
poll_after.assert_status_ok();
let token_body: serde_json::Value = poll_after.json();
assert!(token_body["device_token"].is_string());
// Single use: the same device_code cannot be redeemed twice.
server
.post("/device/token")
.json(&json!({ "device_code": device_code }))
.await
.assert_status(StatusCode::NOT_FOUND);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn confirm_without_identity_is_401() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let code: serde_json::Value = server.post("/device/code").await.json();
server
.post("/device/confirm")
.json(&json!({ "user_code": code["user_code"] }))
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn unknown_device_code_and_user_code_return_404() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config()));
let (account_id, email) = common::register_account(&server).await;
server
.post("/device/token")
.json(&json!({ "device_code": "no-such-code" }))
.await
.assert_status(StatusCode::NOT_FOUND);
server
.post("/device/confirm")
.add_header(common::ACCOUNT_ID_HEADER, account_id.to_string())
.json(&json!({ "user_code": "ZZZZ-ZZZZ" }))
.await
.assert_status(StatusCode::NOT_FOUND);
sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email)
.execute(&pool)
.await
.unwrap();
}

View file

@ -1,16 +0,0 @@
use axum_test::TestServer;
#[tokio::test]
async fn health_returns_ok() {
// NOTE: this test does not touch the DB — pass a pool that is never
// queried. sqlx::PgPool::connect_lazy never opens a connection until
// a query runs, so this is safe without a running Postgres.
let pool = sqlx::PgPool::connect_lazy("postgres://user:pass@localhost/db")
.expect("lazy pool");
let app = accounts_service::build_app(pool);
let server = TestServer::new(app);
let response = server.get("/health").await;
response.assert_status_ok();
response.assert_text("ok");
}

View file

@ -0,0 +1,42 @@
mod common;
use axum_test::TestServer;
#[tokio::test]
async fn health_returns_ok() {
// NOTE: this test does not touch the DB — pass a pool that is never
// queried. sqlx::PgPool::connect_lazy never opens a connection until
// a query runs, so this is safe without a running Postgres.
let pool = sqlx::PgPool::connect_lazy("postgres://user:pass@localhost/db")
.expect("lazy pool");
let app = accounts_service::build_app(pool, &common::test_config());
let server = TestServer::new(app);
let response = server.get("/health").await;
response.assert_status_ok();
response.assert_text("ok");
}
#[tokio::test]
async fn migrations_create_accounts_table() {
let pool = common::test_pool().await;
let row: (Option<String>,) = sqlx::query_as("SELECT to_regclass('accounts')::text")
.fetch_one(&pool)
.await
.expect("query must succeed");
assert!(row.0.is_some(), "accounts table must exist after migrations run");
}
#[tokio::test]
async fn api_routes_require_internal_key_but_health_does_not() {
let pool = common::test_pool().await;
// A raw server: no internal key header on any request, as if the
// service were reached directly, bypassing the gateway.
let server = axum_test::TestServer::new(accounts_service::build_app(pool, &common::test_config()));
server.get("/health").await.assert_status_ok();
server
.post("/device/code")
.await
.assert_status(axum::http::StatusCode::FORBIDDEN);
}

23
backend/common/Cargo.toml Normal file
View file

@ -0,0 +1,23 @@
[package]
name = "common"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
uuid = { version = "1", features = ["v4", "serde"] }
subtle = "2"
tonic = "0.14"
tonic-prost = "0.14"
prost = "0.14"
[build-dependencies]
tonic-prost-build = "0.14"
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
axum-test = "21"

4
backend/common/build.rs Normal file
View file

@ -0,0 +1,4 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_prost_build::compile_protos("proto/accounts.proto")?;
Ok(())
}

View file

@ -0,0 +1,13 @@
syntax = "proto3";
package accounts.v1;
// Internal-only API of accounts-service. Never exposed publicly; every call
// must carry the x-lovisual-internal-key metadata entry.
service AccountsInternal {
// Resolves a mod's long-lived device token to its account and bumps
// device_links.last_seen. UNAUTHENTICATED if the token is unknown/revoked.
rpc AuthenticateDevice(AuthenticateDeviceRequest) returns (AuthenticateDeviceReply);
}
message AuthenticateDeviceRequest { string device_token = 1; }
message AuthenticateDeviceReply { string account_id = 1; }

View file

@ -0,0 +1,176 @@
//! Contract between the gateway and internal services. Services trust the
//! identity header ONLY because `require_internal_key` guarantees the request
//! came through the gateway (which strips client-supplied copies of both).
use axum::{
extract::{FromRequestParts, Request, State},
http::{request::Parts, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use std::sync::Arc;
use subtle::ConstantTimeEq;
use tonic::{
metadata::{Ascii, MetadataValue},
service::Interceptor,
Status,
};
use uuid::Uuid;
pub const INTERNAL_KEY_HEADER: &str = "x-lovisual-internal-key";
pub const ACCOUNT_ID_HEADER: &str = "x-lovisual-account-id";
pub const DEVICE_TOKEN_PREFIX: &str = "lvd_";
fn keys_match(given: &[u8], expected: &[u8]) -> bool {
given.ct_eq(expected).into()
}
#[derive(Clone)]
pub struct InternalKey(Arc<str>);
impl InternalKey {
pub fn new(key: String) -> Self {
InternalKey(key.into())
}
}
pub async fn require_internal_key(State(key): State<InternalKey>, req: Request, next: Next) -> Response {
let ok = req
.headers()
.get(INTERNAL_KEY_HEADER)
.is_some_and(|v| keys_match(v.as_bytes(), key.0.as_bytes()));
if !ok {
return (StatusCode::FORBIDDEN, Json(json!({ "error": "forbidden" }))).into_response();
}
next.run(req).await
}
/// The authenticated account, as resolved by the gateway. Rejects with 401
/// when the gateway forwarded the request anonymously.
pub struct GatewayIdentity {
pub account_id: Uuid,
}
impl<S: Send + Sync> FromRequestParts<S> for GatewayIdentity {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.headers
.get(ACCOUNT_ID_HEADER)
.and_then(|v| v.to_str().ok())
.and_then(|s| Uuid::parse_str(s).ok())
.map(|account_id| GatewayIdentity { account_id })
.ok_or_else(|| {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "unauthorized" }))).into_response()
})
}
}
/// Server-side tonic interceptor: rejects calls without the internal key.
#[derive(Clone)]
pub struct GrpcKeyCheck(Arc<str>);
impl GrpcKeyCheck {
pub fn new(key: &str) -> Self {
GrpcKeyCheck(key.into())
}
}
impl Interceptor for GrpcKeyCheck {
fn call(&mut self, req: tonic::Request<()>) -> Result<tonic::Request<()>, Status> {
match req.metadata().get(INTERNAL_KEY_HEADER) {
Some(v) if keys_match(v.as_bytes(), self.0.as_bytes()) => Ok(req),
_ => Err(Status::permission_denied("missing or invalid internal key")),
}
}
}
/// Client-side tonic interceptor: attaches the internal key to every call.
#[derive(Clone)]
pub struct GrpcKeyAttach(MetadataValue<Ascii>);
impl GrpcKeyAttach {
pub fn new(key: &str) -> Result<Self, tonic::metadata::errors::InvalidMetadataValue> {
Ok(GrpcKeyAttach(MetadataValue::try_from(key)?))
}
}
impl Interceptor for GrpcKeyAttach {
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, Status> {
req.metadata_mut().insert(INTERNAL_KEY_HEADER, self.0.clone());
Ok(req)
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{routing::get, Router};
use axum_test::TestServer;
const KEY: &str = "internal-key-internal-key-internal!!";
fn app() -> Router {
Router::new()
.route("/whoami", get(|id: GatewayIdentity| async move { id.account_id.to_string() }))
.route("/open", get(|| async { "open" }))
.layer(axum::middleware::from_fn_with_state(InternalKey::new(KEY.into()), require_internal_key))
}
#[tokio::test]
async fn request_without_internal_key_is_forbidden() {
let server = TestServer::new(app());
server.get("/open").await.assert_status(axum::http::StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn request_with_wrong_internal_key_is_forbidden() {
let server = TestServer::new(app());
server
.get("/open")
.add_header(INTERNAL_KEY_HEADER, "nope")
.await
.assert_status(axum::http::StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn correct_key_passes_and_identity_is_read() {
let server = TestServer::new(app());
let id = Uuid::new_v4();
let res = server
.get("/whoami")
.add_header(INTERNAL_KEY_HEADER, KEY)
.add_header(ACCOUNT_ID_HEADER, id.to_string())
.await;
res.assert_status_ok();
assert_eq!(res.text(), id.to_string());
}
#[tokio::test]
async fn missing_or_garbage_identity_is_401() {
let server = TestServer::new(app());
server
.get("/whoami")
.add_header(INTERNAL_KEY_HEADER, KEY)
.await
.assert_status_unauthorized();
server
.get("/whoami")
.add_header(INTERNAL_KEY_HEADER, KEY)
.add_header(ACCOUNT_ID_HEADER, "not-a-uuid")
.await
.assert_status_unauthorized();
}
#[test]
fn grpc_key_check_accepts_only_the_right_key() {
let mut check = GrpcKeyCheck::new(KEY);
let mut attach = GrpcKeyAttach::new(KEY).unwrap();
let ok = attach.call(tonic::Request::new(())).unwrap();
assert!(check.call(ok).is_ok());
assert!(check.call(tonic::Request::new(())).is_err());
}
}

150
backend/common/src/jwt.rs Normal file
View file

@ -0,0 +1,150 @@
use axum::http::{header::AUTHORIZATION, HeaderMap};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Distinguishes token purposes so a long-lived refresh/device token can
/// never be replayed as a short-lived access token (and vice versa).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TokenType {
Access,
Refresh,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
pub token_type: TokenType,
}
fn issue(account_id: Uuid, secret: &str, token_type: TokenType, ttl_seconds: i64) -> String {
let exp = (chrono::Utc::now() + chrono::Duration::seconds(ttl_seconds)).timestamp() as usize;
let claims = Claims { sub: account_id.to_string(), exp, token_type };
encode(&Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.expect("encoding a well-formed Claims struct cannot fail")
}
pub fn issue_access_token(account_id: Uuid, secret: &str) -> String {
issue(account_id, secret, TokenType::Access, 15 * 60)
}
/// Temporary — deleted in Task 4 once opaque refresh tokens land.
pub fn issue_refresh_token(account_id: Uuid, secret: &str) -> String {
issue(account_id, secret, TokenType::Refresh, 30 * 24 * 60 * 60)
}
/// Returns the claims only if the signature, expiry AND token type all match.
/// HS256 is pinned explicitly (no algorithm confusion) and expiry leeway is 0.
pub fn verify_token(token: &str, secret: &str, expected: TokenType) -> Option<Claims> {
let mut validation = Validation::new(Algorithm::HS256);
validation.leeway = 0;
validation.set_required_spec_claims(&["exp", "sub"]);
let claims = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.ok()?
.claims;
(claims.token_type == expected).then_some(claims)
}
/// The raw token from `Authorization: Bearer <token>`, if the header is
/// present, valid ASCII, uses the Bearer scheme and is non-empty.
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
let value = headers.get(AUTHORIZATION)?.to_str().ok()?;
let token = value.strip_prefix("Bearer ")?.trim();
(!token.is_empty()).then_some(token)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_token_round_trips() {
let id = Uuid::new_v4();
let token = issue_access_token(id, "test-secret");
let claims = verify_token(&token, "test-secret", TokenType::Access).expect("should decode");
assert_eq!(claims.sub, id.to_string());
assert_eq!(claims.token_type, TokenType::Access);
}
#[test]
fn wrong_secret_fails_verify() {
let token = issue_access_token(Uuid::new_v4(), "test-secret");
assert!(verify_token(&token, "other-secret", TokenType::Access).is_none());
}
#[test]
fn garbage_token_fails_verify() {
assert!(verify_token("not.a.jwt", "test-secret", TokenType::Access).is_none());
}
#[test]
fn refresh_token_is_rejected_where_access_is_expected() {
let token = issue_refresh_token(Uuid::new_v4(), "test-secret");
assert!(verify_token(&token, "test-secret", TokenType::Access).is_none());
assert!(verify_token(&token, "test-secret", TokenType::Refresh).is_some());
}
#[test]
fn access_token_is_rejected_where_refresh_is_expected() {
let token = issue_access_token(Uuid::new_v4(), "test-secret");
assert!(verify_token(&token, "test-secret", TokenType::Refresh).is_none());
}
#[test]
fn expired_token_fails_verify() {
let token = issue(Uuid::new_v4(), "test-secret", TokenType::Access, -10);
assert!(verify_token(&token, "test-secret", TokenType::Access).is_none());
}
#[test]
fn token_signed_with_other_algorithm_is_rejected() {
let claims = Claims {
sub: Uuid::new_v4().to_string(),
exp: (chrono::Utc::now().timestamp() + 600) as usize,
token_type: TokenType::Access,
};
let hs512 = encode(
&Header::new(Algorithm::HS512),
&claims,
&EncodingKey::from_secret(b"test-secret"),
)
.unwrap();
assert!(verify_token(&hs512, "test-secret", TokenType::Access).is_none());
}
use axum::http::HeaderValue;
fn headers_with(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_str(value).unwrap());
headers
}
#[test]
fn bearer_token_extracts_the_token() {
let mut h = HeaderMap::new();
h.insert(AUTHORIZATION, "Bearer abc.def".parse().unwrap());
assert_eq!(bearer_token(&h), Some("abc.def"));
}
#[test]
fn bearer_token_rejects_missing_wrong_scheme_and_empty() {
let mut h = HeaderMap::new();
assert_eq!(bearer_token(&h), None);
h.insert(AUTHORIZATION, "Basic abc".parse().unwrap());
assert_eq!(bearer_token(&h), None);
h.insert(AUTHORIZATION, "Bearer ".parse().unwrap());
assert_eq!(bearer_token(&h), None);
}
#[test]
fn bearer_token_rejects_garbage_headers() {
assert_eq!(bearer_token(&headers_with("Bearer not.a.jwt")).unwrap(), "not.a.jwt");
}
}

View file

@ -0,0 +1,8 @@
pub mod internal;
pub mod jwt;
pub mod pb {
pub mod accounts {
tonic::include_proto!("accounts.v1");
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +0,0 @@
# configs-service (planned)
4 cloud config slots per account, share codes, public showcase (Подсистема 1,
часть 2). See `TODO.md` (Фаза 10) and `backend/STRUCTURE.md`. No
implementation plan yet.

2345
backend/gateway/PLAN.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
# gateway (planned)
Routing + single JWT check point + rate limits in front of `accounts-service`,
`configs-service`, `chat-service`. See `TODO.md` (Фаза 10) and
`backend/STRUCTURE.md`. No implementation plan yet — starts after
`accounts-service` (`backend/PLAN.md`) is done.