Автентифікація
Ця сторінка описує, як бекенд впізнає користувача: сесії в Redis з HMAC-підписаною cookie, способи входу, як залогінити й розлогінити користувача з коду, авторизацію WebSocket і ролі. Захист трафіку (rate limiting, CSRF, CORS, CSP, проксі) описано на сторінці Безпека, вхід через Google, GitHub та інші — на сторінці Вхід через соцмережі.
Коротко на прикладі: що відбувається після входу
Цей ланцюжок можна пройти в браузері (DevTools → Network) або через curl, див. Перший сценарій.
GET /api/main/initбез cookie. Сервер створює гостьову сесію в Redis, ставить cookieuapiі повертає401зcsrfToken.POST /api/auth/loginз email, паролем і заголовкомx-csrf-token. Сервер перевіряє пароль, прив'язує сесію до користувача і оновлює cookie:textSet-Cookie: uapi=<…>; Path=/; Expires=…; HttpOnly; Max-Age=86400; SameSite=LaxНеправильний пароль дає
401 {"message":"Invalid email or password","code":"unauthorized"}, а після 10 невдалих спроб поспіль з однієї адреси сервер відповідає429із заголовкомRetry-After: 60(перевірено з налаштуваннями за замовчуванням; ліміти описано нижче).Кожен наступний запит несе cookie автоматично. Middleware
session_webзнаходить сесію в Redis, звіряє їїuserTokenзusers.session_tokenу БД (див. Відкликання сесій) і знає, хто ви.GET /api/main/initтепер повертає200з даними користувача,wsTokenі новимcsrfToken.Захищені маршрути.
auth_guardпропускає лише сесію з користувачем (інакше401),admin_guard— лише рольadmin(інакше403 Admin access required).POST /api/auth/logoutвидаляє сесію з Redis. Стара cookie більше нічого не відкриває,initзнову дає401.logout-allротуєusers.session_token, чим відкликає всі сесії користувача (вихід на всіх пристроях), і видаляє їхні записи з Redis.
Що лежить у Redis після такого сценарію (ключі з префіксом REDIS_PREFIX):
uwebsocket:session:<userId>:<sessionId> сесія залогіненого користувача
uwebsocket:session:anon_session:<sessionId> гостьова сесія, лише якщо вона зберігає дані (OAuth state)
uwebsocket:auth:throttle:login_ip:127.0.0.1 лічильник спроб входу з IP
uwebsocket:auth:ws:<token> одноразовий WebSocket-токен (60 с)Звичайна гостьова сесія (до входу) в Redis не пишеться: вона живе в підписаній cookie (див. Анонімна сесія).
Нижче описано формат cookie, підпис, ротацію і решту механізмів.
Вхід і вихід у коді
Розробнику не треба працювати з cookie, Redis чи підписами напряму. У кожному HTTP-контексті є два об'єкти: context.auth (хто користувач, вхід і вихід) і context.session (дані поточної сесії). Їх заповнює middleware session_web (або session_api), тож маршрут має стояти в групі з ним. Типи — Auth і Session у vendor/types/types.d.ts, реалізація — session-handler.ts.
context.auth
| Метод | Повертає | Що робить |
|---|---|---|
check() | boolean | true, якщо в сесії є userId, тобто користувач залогінений. Те саме перевіряє auth_guard. |
getUserId() | string | null | id користувача з сесії рядком або null для гостя. Для запиту до БД перетворіть на BigInt(userId). |
login(userId, userToken) | Promise<boolean> | Логінить користувача: видаляє поточну сесію (гостьову чи іншого користувача), створює нову з новим sessionId і CSRF-токеном, ставить cookie у відповідь. userId — String(user.id), userToken — user.sessionToken з таблиці users. Якщо будь-який аргумент undefined, нічого не робить і повертає false. Помилку Redis кидає далі. |
logout() | Promise<boolean> | Розлогінює на цьому пристрої: видаляє поточну сесію і видає нову гостьову з новим CSRF-токеном. |
logoutAll() | Promise<number> | Виходить на всіх пристроях: видаляє записи всіх сесій користувача, повертає їх кількість і видає нову гостьову сесію. Сама по собі лише чистить Redis; відкликання забезпечує ротація users.session_token, яку маршрут logout-all робить у authService.logoutAll перед цим викликом. Для гостя повертає 0. Якщо не встиг за дедлайн, кидає помилку, яка стає 503 (див. Redis як джерело істини). |
Після login, logout і logoutAll об'єкти context.auth і context.session у тому ж запиті вже дивляться на нову сесію: getUserId() одразу повертає нового користувача, а context.session.sessionInfo.data.csrfToken — новий токен, який треба віддати клієнту.
context.session
| Член | Що робить |
|---|---|
sessionInfo | Поточна сесія { id, data, createdAt, updatedAt? } або null. У data лежать userId, userToken, csrfToken, wsToken і ваші власні поля. |
updateSessionData(data) | Зливає data з наявними даними сесії і зберігає в Redis. Так OAuth зберігає oauthState між редиректом і callback. Щоб прибрати поле, передайте undefined. |
changeSessionData(data) | Замінює дані сесії повністю. Обережно: без userId/userToken/csrfToken у новому об'єкті користувач фактично розлогіниться, а CSRF перестане працювати. |
destroySession() | Видаляє поточну сесію з Redis без видачі нової. Для виходу користуйтеся auth.logout(), який ще й видає гостьову сесію та cookie. |
destroyAllSessions | У HTTP-контексті не заповнюється і повертає 0. Щоб вийти на всіх пристроях, використовуйте auth.logoutAll(). |
Приклад: свій спосіб входу
Типова схема — сервіс знаходить користувача, перевіряє облікові дані й викликає auth.login. Так влаштовано auth-service.ts (функція buildAuthSuccess, яка ще й видає wsToken); нижче спрощений варіант:
import type { Auth, Session } from '#vendor/types/types.js'
async function signIn(auth: Auth, session: Session, email: string, password: string) {
const user = await userRepository.findByEmail(email)
if (user === undefined || !(await validatePassword(password, user.password))) {
return Result.err(unauthorized('Invalid email or password'))
}
// нова сесія, нова cookie, новий CSRF-токен
await auth.login(String(user.id), user.sessionToken)
return Result.ok({
user: userTransformer.serialize(user),
csrfToken: session.sessionInfo?.data.csrfToken,
})
}Контролер передає в сервіс context.auth і context.session і повертає результат. Новий csrfToken обов'язково віддайте клієнту: старий після входу більше не приймається (403 token_mismatch).
Приклад: вихід
async logout(context: HttpContext<EmptyFormInput>): Promise<LogoutResponse> {
await context.auth.logout()
return { status: 'success', csrfToken: context.session.sessionInfo?.data.csrfToken }
}Реальний auth-controller.ts робить те саме через authService.logout(context.auth, context.session) і обробляє помилки через Result. Вихід на всіх пристроях — authService.logoutAll, який ще й надсилає push-сповіщення безпеки.
Перевірити, хто робить запит
async getProfile(context: HttpContext): Promise<ProfileResponse> {
const userId = context.auth.getUserId()
if (userId === null) {
// сюди не дійде, якщо маршрут закрито auth_guard
context.responseData.status = 401
return { status: 'error', message: 'Unauthorized' }
}
const user = await userRepository.findById(BigInt(userId))
// ...
}Зазвичай перевірку робить не контролер, а auth_guard на маршруті (див. Middleware). Тоді в контролері getUserId() гарантовано не null.
WebSocket
У WS-контексті context.auth і context.session дорівнюють null: користувач визначається під час підключення, а в обробнику доступний context.userData. Див. Авторизація WebSocket.
Сесії
Формат cookie
Сесію створює і перевіряє session-handler.ts, а підпис робить token-handler.ts.
- Значення до підпису:
<userToken>.<sessionId>. Для анонімної сесії, збереженої в Redis, замістьuserTokenстоїть рядокanon_session. Анонімна сесія без Redis має власний формат — див. Анонімна сесія. sessionIdгенерується черезcrypto.randomUUID().- Підпис: HMAC-SHA256 з ключем
APP_KEY, hex-рядок обрізаний до 32 символів. Підпис дописується через крапку, а результат кодується в base64:base64("<userToken>.<sessionId>.<signature>"). - Під час перевірки підписи порівнюються через
crypto.timingSafeEqual. Токен має складатися рівно з 3 частин, аuserTokenмає відповідати шаблону^[a-zA-Z0-9_-]{10,}$. - Якщо
APP_KEYне задано, він коротший за 32 символи або дорівнюєyour-secret-keyчиchange-me, модуль кидає помилку під час завантаження, і сервер не стартує.
Ім'я та атрибути cookie задає config/session.ts:
| Параметр | Значення |
|---|---|
| Ім'я | __Host-uapi, якщо SESSION_COOKIE_HOST_PREFIX увімкнено (за замовчуванням вмикається, коли appConfig.isProductionLike), інакше uapi |
path / httpOnly | / / true |
secure | true для будь-якого APP_ENV, крім local |
sameSite | Lax |
TTL (age, maxAge, Redis) | 24 години (duration('24h')) для сесії користувача |
| TTL анонімної сесії | SESSION_ANON_TTL (anonAge), за замовчуванням 2h: і для cookie, і для анонімної сесії в Redis |
Поки триває перехід на нове ім'я, resolve-session-cookie.ts спершу шукає активне ім'я cookie, а потім застаріле uapi. Якщо знайдено лише застаріле ім'я, delete-legacy-session-cookie.ts надсилає Set-Cookie, який видаляє стару cookie. Під час старту validate-runtime-config.ts перевіряє, що при __Host- префіксі cookie має Secure=true, а APP_URL починається з https://.
Анонімна сесія
Запит без cookie (або з невалідною чи простроченою) отримує анонімну сесію з CSRF-токеном — без запису в Redis (feat-057). Інакше кожен запит бота до main/init залишав би в Redis сесію на добу.
- Cookie:
base64("anon-v1.<sessionId>.<csrfToken>.<issuedAt>.<signature>"), підпис — той самий HMAC зAPP_KEY. Префіксanon-v1розділяє підписи: cookie сесії користувача не перевіряється як анонімна і навпаки. - Дійсна, поки з
issuedAtминуло меншеSESSION_ANON_TTL. Коли їй більше 15 хвилин, сервер видає її знову з новимissuedAtі тим самимsessionIdта токеном (ковзний строк дії, лишеSet-Cookie). - Повторно видана анонімна cookie проходить ту саму перевірку перед відправкою (
beforeSend), що й cookie сесії в Redis: якщо тим часом інший запит цієї сесії виконав вхід (вхід лишає маркерsession_superseded:<sessionId>на 15 хвилин для будь-якої попередньої анонімної сесії) або переніс її в Redis (session:anon_session:<sessionId>), повільна відповідь свою cookie не надсилає. - Запит, що сам переніс сесію в Redis, перед відправкою перевіряє лише маркер входу: власний запис у Redis не заважає його cookie, а вхід, зроблений тим часом, — заважає. Вхід також видаляє запис
anon_session, якщо паралельний запит устиг його створити. - Запит, що сам виконав вхід або вихід, свою cookie зберігає. Якщо перевірка не вдалася (Redis недоступний), cookie з відповіді прибирається.
- Cookie, знайдена лише під застарілим ім'ям
uapi, видається під активним ім'ям одразу, незалежно від віку. - CSRF працює як раніше:
sessionInfo.data.csrfTokenбереться з cookie,session_webвіддає його в 401/403,csrf_guardпорівнює його з заголовком. - Відкликати таку cookie до кінця строку дії не можна. Прав вона не дає: після входу стара анонімна cookie відкриває лише анонімну сесію, а її токен не підходить до сесії користувача.
- Щойно код записує в анонімну сесію дані (
updateSessionData/changeSessionData, напр. OAuthstate), вона переходить у Redis якsession:anon_session:<sessionId>з TTLSESSION_ANON_TTL, а cookie — у форматanon_session.<sessionId>.
Redis як джерело істини
Відкликання сесій
Запис сесії в Redis доводить лише, що сесію колись видали. Тому відкликання не спирається на видалення ключів (воно може не дійти до Redis), а на users.session_token у PostgreSQL (ADR 0002):
- Перевірка на кожному автентифікованому запиті.
sessionHandler(session_web,session_api),wsSessionHandler(кожне WS-повідомлення) іverifyUpgradeToken(підключення WS) порівнюютьuserTokenсесії з актуальнимusers.session_token, прочитаним із БД без кешу (одне читання за первинним ключем). Збіг — сесія діє; розбіжність або видалений користувач — сесія вважається відкликаною: запит стає анонімним (401наauth_guard), запис у Redis прибирається за можливості. Анонімні сесії без користувача цієї перевірки не мають. - Fail-closed, дедлайн і скасування. Читання обмежене
SESSION_TOKEN_CHECK_TIMEOUT_MS(за замовчуванням 2000 мс, див. Конфігурацію): БД, яка прийняла запит і не відповідає, дає503, а не запит, що чекає без кінця. Дедлайну в коді застосунку немає: читання йде через окремий пул з 5 зʼєднань (session-check-db.ts), у якогоstatement_timeoutдорівнює дедлайну, тож сервер сам скасовує запит (57014) і слот не лишається зайнятим. Якщо сервер не відповідає взагалі (розрив мережі), черезtimeout + 500 мсспрацьовуєquery_timeoutдрайвера: зʼєднання знищується, слот звільняється. Окремий пул потрібен, боstatement_timeout— властивість зʼєднання і на основному пулі обмежив би всі запити застосунку; побічно він ізолює автентифікацію від довгих бізнес-запитів. Після помилкиpg-poolвідкриває нове зʼєднання, тож під час тривалого збою кожна перевірка, що вичерпала час, платить за рукостискання (кількість обмежена розміром пулу). Якщо БД не відповіла, запит не обслуговується за одним записом Redis: HTTP відповідає503 service_unavailableзRetry-After: 1(без тексту драйвера), WS-підключення і повідомлення — також503/відмовою. Запис Redis при цьому не видаляється: щойно БД відповідає, та сама cookie знову працює. - Три операції відкликають, ротуючи токен у БД першим кроком:
change-password(один умовнийUPDATE … WHERE password = <поточний хеш>, тож із двох паралельних змін з однаковим старим паролем успішна рівно одна, друга отримує409),reset-password/reset-phone/completeіlogout-all. Поточний клієнт післяchange-passwordотримує нову сесію вже під новим токеном. destroyAllSessions— лише очищення пам'яті. Його збій (Redis недоступний, вичерпано дедлайн) логується й не змінює результат: записи, що лишилися, уже відхиляються перевіркою, а решту прибирає TTL. Після збою Redis перший же запит зі старою cookie дає401— повторного очищення не потрібно.- Ціна. +1 читання за первинним ключем на автентифікований запит і WS-повідомлення. Кеш цього значення можливий, але свідомо не вводився: він додав би вікно, у якому відкликана сесія ще діє (збій інвалідації, гонка читання й інвалідації).
Що таке userToken
userToken — це колонка users.session_token (nanoid(), 21 символ, унікальна, задається при створенні користувача). Це не сесія і не секрет для входу, а постійний простір імен сесій користувача в Redis:
- усі сесії одного користувача на всіх пристроях лежать під
session:<userToken>:*, тожlogoutAllзнаходить їх однимSCAN MATCHбез окремого індексу «користувач → сесії»; - зміна пароля, скидання пароля (за email і за SMS) і
logout-allзамінюютьsession_tokenна новийnanoid(): сесії зі старим значенням відкликані, навіть якщо їхні ключі ще лежать у Redis (див. Відкликання сесій); - значення входить у підписану cookie (
<userToken>.<sessionId>.<signature>), тож сервер одразу знає, де шукати сесію в Redis, а актуальність значення звіряє з БД.
Зберігається воно в PostgreSQL, бо мусить пережити всі сесії: нова сесія після логіну бере userToken з рядка користувача, а Redis зберігає лише живі сесії з TTL. Без постійного значення в БД кожен вхід створював би новий простір імен, і logoutAll не бачив би сесій з інших пристроїв.
Клієнту userToken не потрібен: відповіді з користувачем (user-transformer.ts) його не містять. Сам по собі він сесію не відкриває — потрібні ще sessionId і підпис APP_KEY, — але й світити його в JSON, доступному з JavaScript, немає причин.
Сесії зберігає redis-session-storage.ts:
- Ключ має вигляд
session:<userToken|anon_session>:<sessionId>. Перед кожною операцієюsanitizeRedisKeyперевіряє його: дозволено лише[a-zA-Z0-9:_*-], довжина не більше 86 символів. - Запис робиться через
SETEX, читання — черезGETEX ... EX, з TTLageдля сесії користувача іanonAgeдляanon_session. Тобто кожне читання продовжує життя сесії (ковзний TTL). - Якщо підпис валідний, але ключа в Redis немає, видається нова анонімна сесія (у cookie, без запису).
destroyAllSessions(викликlogoutAll) знаходить ключі черезSCAN MATCH session:<userToken>:*порціями по 100, а не черезKEYS.- Межа
logoutAll. Увесь циклSCAN/DELобмежений дедлайномREDIS_LOGOUT_ALL_DEADLINE_MS(2000 мс), що рахується від його початку. Перед кожнимawaitобчислюється залишок бюджету, і команда очікується не довшеmin(залишок, REDIS_COMMAND_TIMEOUT_MS). Успіх (deletedCount) повертається лише якщоSCANдійшов до курсора0, тобто всі сесії видалено. Якщо дедлайн вичерпано або команда отрималаRedisUnavailableError—503 service_unavailableзRetry-After: 1, безdeletedCount. Уже видалені сесії лишаються видаленими (DELідемпотентний), повторний виклик дочищає решту. Це поведінка сховища;503тут означає лише, що записи не вдалося видалити: черезauthService.logoutAllсесії вже відкликані ротацієюusers.session_token, а записи, що лишилися, дочищаються при наступному повному відкликанні або спливають за TTL:- поточна сесія видаляється останньою, лише після курсора
0. Клієнт, який викликаєauth.logoutAll()напряму (без ротації токена), після503лишається автентифікованим і може повторити запит; маршрутlogout-allтак не працює; - курсор
SCANзберігається вlogout_all:cursor:<userToken>:<sessionId>(TTL 600 с) після кожної порції, і повторlogoutAllз тієї самої сесії продовжує з нього, а не з0, тому на великій базі повтори просуваються. Сесії, створені між спробами позаду збереженого курсора, продовжений обхід не бачить; - повне відкликання (скидання пароля, виклик без поточної сесії) завжди обходить базу з
0і курсор не читає й не пише: прогрес перерваногоlogoutAll, який пропустив свою сесію, не може приховати її від відкликання.
- поточна сесія видаляється останньою, лише після курсора
Middleware session_web (session-web.ts) читає cookie, а session_api (session-api.ts) бере той самий підписаний токен із заголовка Authorization: Bearer <token>. Cookie нової сесії видається одразу, а cookie незміненої сесії в Redis — повторно не частіше ніж раз на годину, щоб продовжити строк дії. Момент оновлення визначає окремий ключ session_cookie_refresh:<sessionId> (SET NX EX 3600): запис сесії при цьому не переписується, тож оновлення не може відновити сесію, яку інший запит щойно видалив. Нова сесія користувача ставить цей ключ одразу; якщо запис ключа не вдається, сесія все одно створюється (помилка лише логується), і cookie просто буде оновлено раніше. Перед відправкою відповіді (context.session.beforeSend після хендлера) сервер перевіряє, що сесія, чию cookie повторно видає запит, ще існує: повільна відповідь не повертає браузеру cookie сесії, яку тим часом замінив інший запит (зміна пароля, logout-all). Запити, що самі змінили сесію (login, logout), свою cookie зберігають. Якщо ця перевірка не вдається (Redis недоступний), cookie з відповіді прибирається, а відповідь хендлера лишається без змін. Залишковий ризик: браузер може отримати відповіді в іншому порядку, ніж сервер їх відправив, і зберегти cookie вже видаленої сесії. Тоді наступний запит отримує нову анонімну сесію (користувач входить знову); прав видаленої сесії він не отримує.
Ротація
auth.login(userId, userToken)видаляє попередню сесію з Redis (автентифіковану або збережену анонімну) і створює нову з новимsessionIdта новим CSRF-токеном. Стара cookie сесії з Redis після цього відкриває нову анонімну сесію з іншим токеном, тож мутація зі старим токеном отримує403 token_mismatch. Анонімна cookie без Redis лишається дійсною до кінця свого строку, але відкриває лише анонімну сесію; токен з неї не підходить до сесії користувача. Це захищає від session fixation.auth.logout()видаляє поточну сесію і видає нову анонімну (у cookie).auth.logoutAll()видаляє записи всіх сесій користувача і видає нову анонімну (у cookie). Відкликання — це ротаціяusers.session_tokenуauthService.logoutAll, яка йде першою.- Після
login/logout/logoutAllхелпериcontext.session.*у тому ж запиті працюють уже з новою сесією. - Ротація лише CSRF-токена без зміни
sessionIdне використовується: зміна автентифікації завжди означає новийsessionId.
Коли змінюється сесія (аудит переходів автентифікації):
| Потік | Що відбувається з сесією |
|---|---|
| Вхід (email/пароль, телефон), OAuth-callback | auth.login: новий sessionId і токен, попередня сесія видалена |
register/phone/complete-profile | auth.login після створення користувача |
register/email | анонімна сесія видаляється; вхід — після підтвердження email |
register/phone/confirm, verify-email | сесія не змінюється: лише підтвердження коду або адреси, права не змінюються; токен пошти споживається атомарно в одній транзакції з її підтвердженням |
change-password | authService.renewSession: новий sessionId, CSRF- і WS-токен для того самого користувача; відповідь sessionRenewed: true з csrfToken, wsUrl, wsToken, і клієнт перепідключає WebSocket. Пароль і новий users.session_token пишуться одним умовним UPDATE: усі інші сесії відкликані, а нова видається вже зі свіжим токеном. Якщо видача нової сесії падає, відповідь лишається успіхом з sessionRenewed: false (попередня сесія вже відкликана — клієнт входить знову) |
reset-password, reset-phone/complete | анонімний запит; токен скидання (email) споживається в одній транзакції зі зміною пароля й ротацією users.session_token (це і є відкликання): із двох паралельних запитів з одним посиланням успішний один, а збій після запису відкочує все, і посилання лишається придатним; destroyAllSessions виконується після коміту, лише чистить записи в Redis, і його збій не зупиняє скидання |
link-phone/confirm, link-email/* | сесія не змінюється: додається ідентифікатор, права не змінюються |
| Зміна ролі | ендпоінта немає (роль міняють у БД); admin_guard читає роль із бази на кожному запиті, а відкрите WS-з'єднання втрачає admin-доступ за WS_ADMIN_RECHECK_INTERVAL_MS (WebSocket) |
Способи входу
Маршрути групи auth оголошено в http-routes.ts. Група має middleware session_web і csrf_guard та rate limit 10 запитів на хвилину з IP на кожен маршрут групи (окремі маршрути мають суворіші власні ліміти). Контролери: auth-controller.ts і oauth-controller.ts.
- Email і пароль:
POST /auth/register/emailдля реєстрації таPOST /auth/loginдля входу. Схема входуlogin.tsприймає абоemail, абоidentifier, разом ізpassword(8–32 символи). Паролі хешуєhashPasswordзmetautil(auth-service.ts). Є такожforgot-password,reset-password,verify-email,resend-verification-email,change-password,link-email/start.- Вхід у непідтверджений акаунт (за
AUTH_STRICT_REGISTRATION=true) з правильним паролем відповідає тим самим401 Invalid email or password, що й неправильний пароль: окрема відповідь («Email verification required»,400) видавала б кожному, хто підбирає пароль, що акаунт існує і пароль вгадано. Спроба рахується як невдала (лічильник входу акаунта не скидається). Власнику, який знає пароль, у фоні надсилається лист підтвердження ще раз — не частіше заAUTH_LOGIN_UNVERIFIED_RESEND_INTERVAL_MS(5 хв) на акаунт; на відповідь вхід це не впливає, збій листа лише логується. Фронтенд показує для цього загальне повідомлення про невдалий вхід. - Реєстрація хешує пароль до пошуку email, тож заняту адресу й нову обробляють однаково довго (хешування займає десятки мілісекунд, і за часом відповіді можна було б визначити, чи адреса зареєстрована). Відповідь для обох однакова.
- Вхід у непідтверджений акаунт (за
- Телефон і SMS:
register/phone/start,register/phone/confirm,register/phone/complete-profile,reset-phone/start,reset-phone/complete,link-phone/start,link-phone/confirm. Вмикається черезAUTH_PHONE_ENABLED(config/auth.ts). OTP-коди зберігаються як HMAC-SHA256 з ключемAUTH_OTP_HMAC_KEY(абоAPP_KEY, якщо перший не задано) (otp-verification-hash.ts). Якщо телефонний вхід увімкнено, а ключ коротший за 32 символи, сервер не стартує. Три SMS-флоу (реєстрація, привʼязка, скидання пароля) ділять одну механіку челенджа вsms-challenge.ts:findPendingChallenge(429 до часу повторної відправки),issueSmsChallenge(закрити старі, записати, надіслати код і зберегти лише його хеш; збій провайдера чи запису, як і челендж, закритий за цей час, закриває новий челендж),verifySmsCode(кожен хибний код — спроба; остання закриває челендж) іconfirmChallenge. - Соцмережі (OAuth): Google, Facebook, GitHub і GitLab через бібліотеку
arctic. Що потрібно налаштувати і як додати провайдера — на сторінці Вхід через соцмережі.
Провайдер SMS обирається через AUTH_SMS_PROVIDER. Валідатор runtime-конфігурації забороняє AUTH_SMS_PROVIDER=fake в середовищах production-like і вимагає явно задати AUTH_SMS_PROVIDER_ALLOW_FAKE=true та AUTH_FAKE_SMS_CODE.
Авторизація WebSocket
- Автентифікований клієнт викликає
GET /main/update-ws-token(auth_guard).generate-ws-token-service.tsкладе в Redis ключauth:ws:<token>з TTL 60 с і даними{ sessionId, userId, userToken }. - Клієнт відкриває
/<pathPrefix>/websocketі передає токен першим елементомSec-WebSocket-Protocol.handleUpgradeуws-handlers.tsперевіряє формат токена: довжинаaccessTokenLength, лише символи[a-zA-Z0-9]. Якщо формат невірний, сервер відповідає401. ws-session-auth.ts(verifyUpgradeToken) читає токен, знаходить сесіюsession:<userToken>:<sessionId>і перевіряє, щоuserIdу сесії збігається зuserIdу токені, і звіряєuserTokenзusers.session_token(відкликана сесія → відмова; БД не відповіла →503, а не доступ за одним записом Redis).refreshTokenпродовжує TTL токена до 120 с, аrevokeTokenвидаляє токен під час закриття з'єднання.- Кожне повідомлення проходить через
wsSessionHandler, який так само звіряєuserTokenз БД (недоступна БД → повідомлення не обробляється, клієнт отримує503-повідомлення). Якщо сесії немає, вона відкликана або в ній немаєuserId, клієнт отримує unauthorized-повідомлення, а з'єднання закривається з кодом4001.
Ролі: auth_guard та admin_guard
auth-guard.tsповертає401 unauthorized, якщоauth.check()хибне.admin-guard.tsзавантажує користувача з БД і повертає403 forbidden, якщоrole !== 'admin'. Адмінські групи підключають["session_web", "csrf_guard", "auth_guard", "admin_guard"].