feat(api): sign-in, refresh rotation and bootstrap admin (M1)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
- users, refresh_tokens and audit_log schema (migration 00002) - argon2id passwords with rehash on login, password policy, lockout with exponential back-off and a per-IP rate limit in Redis - Ed25519 JWT access tokens with kid-based key rotation; rotating refresh tokens with reuse detection (cookie for web, body for Android) - bootstrap admin on first start; must_change_credentials and must_change_password gate every other endpoint - /auth/login, /auth/refresh, /auth/logout(-all), /me, /me/password, /me/bootstrap-credentials, /me/sessions; server-info reports bootstrap_required - role policy from docs/auth-and-roles.md with table tests - problem+json errors with field errors; ru/en translations of the codes - install.sh and update.sh generate JWT_PRIVATE_KEY Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b6def39e29
commit
ed4a492dba
37 files changed
+5826
-49
No files matched your search
@@ -59,6 +59,181 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Вход по логину и паролю
|
||||
* @description Веб (`client_type: web`) получает refresh-токен в cookie `pipu_rt`
|
||||
* (`HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth`), Android — в поле `refresh_token`.
|
||||
* Неверный логин или пароль, а также временно заблокированный перебором вход — всегда
|
||||
* `401 auth.invalid_credentials`. Превышен лимит попыток с адреса — `429 auth.too_many_attempts`.
|
||||
*/
|
||||
post: operations["login"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/refresh": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Ротация refresh-токена
|
||||
* @description Refresh-токен берётся из cookie `pipu_rt` (веб) или из тела (Android). Старый токен
|
||||
* становится использованным; его повторное предъявление отзывает всю семью токенов.
|
||||
*/
|
||||
post: operations["refreshTokens"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/logout": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Выход — отзыв текущей сессии */
|
||||
post: operations["logout"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/logout-all": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Выход на всех устройствах */
|
||||
post: operations["logoutAll"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Текущий пользователь */
|
||||
get: operations["getMe"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Изменить имя, язык, настройки интерфейса
|
||||
* @description Поля `ui_prefs` сливаются с сохранёнными; не переданные поля не меняются.
|
||||
*/
|
||||
patch: operations["updateMe"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Сменить пароль
|
||||
* @description `current_password` обязателен, кроме случая `must_change_password` (вход по временному паролю).
|
||||
* Все сессии отзываются; текущему устройству выдаётся новая пара токенов.
|
||||
*/
|
||||
post: operations["changePassword"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/bootstrap-credentials": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Первый запуск — новые логин и пароль администратора
|
||||
* @description Только при `must_change_credentials`. Пароль должен отличаться от текущего.
|
||||
* Все прочие сессии отзываются, выдаётся новая пара токенов.
|
||||
*/
|
||||
post: operations["setBootstrapCredentials"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/sessions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Активные сессии (устройства) */
|
||||
get: operations["listMySessions"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/sessions/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Завершить сессию на устройстве */
|
||||
delete: operations["revokeMySession"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -97,6 +272,95 @@ export interface components {
|
||||
* @example 10099
|
||||
*/
|
||||
min_android_version_code: number;
|
||||
/** @description Первый вход администратора ещё не завершён — клиент может показать подсказку */
|
||||
bootstrap_required: boolean;
|
||||
};
|
||||
/** @enum {string} */
|
||||
Role: "admin" | "moderator" | "user";
|
||||
/** @enum {string} */
|
||||
ClientType: "web" | "android";
|
||||
LoginRequest: {
|
||||
username: string;
|
||||
password: string;
|
||||
client_type: components["schemas"]["ClientType"];
|
||||
/** @description Например «Chrome / Windows», «Pixel 8» */
|
||||
device_name?: string;
|
||||
};
|
||||
RefreshRequest: {
|
||||
/** @description Только Android; веб передаёт токен в cookie */
|
||||
refresh_token?: string;
|
||||
};
|
||||
AuthTokens: {
|
||||
access_token: string;
|
||||
/** @enum {string} */
|
||||
token_type: "Bearer";
|
||||
/** @description Срок жизни access-токена, секунды */
|
||||
expires_in: number;
|
||||
/** @description Только для `client_type: android` */
|
||||
refresh_token?: string;
|
||||
user: components["schemas"]["Me"];
|
||||
};
|
||||
UiPrefs: {
|
||||
/** @enum {string} */
|
||||
theme?: "dark" | "light" | "system";
|
||||
/** @enum {string} */
|
||||
wave_variant?: "orb" | "liquid";
|
||||
/** @enum {string} */
|
||||
web_player?: "bar" | "island";
|
||||
/** @enum {string} */
|
||||
mobile_player?: "cover" | "wave";
|
||||
ambient?: boolean;
|
||||
crossfade_s?: number;
|
||||
normalize?: boolean;
|
||||
};
|
||||
Me: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
role: components["schemas"]["Role"];
|
||||
locale: string;
|
||||
ui_prefs: components["schemas"]["UiPrefs"];
|
||||
must_change_password: boolean;
|
||||
must_change_credentials: boolean;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
};
|
||||
UpdateMeRequest: {
|
||||
/** @description Пустая строка — сбросить имя */
|
||||
display_name?: string;
|
||||
locale?: string;
|
||||
ui_prefs?: components["schemas"]["UiPrefs"];
|
||||
};
|
||||
ChangePasswordRequest: {
|
||||
current_password?: string;
|
||||
new_password: string;
|
||||
};
|
||||
BootstrapCredentialsRequest: {
|
||||
new_username: string;
|
||||
new_password: string;
|
||||
};
|
||||
Session: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
device_name: string | null;
|
||||
client_type: components["schemas"]["ClientType"];
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Время входа
|
||||
*/
|
||||
created_at: string;
|
||||
/** Format: date-time */
|
||||
last_used_at: string;
|
||||
/** @description Сессия, из которой сделан запрос */
|
||||
current: boolean;
|
||||
};
|
||||
SessionList: {
|
||||
items: components["schemas"]["Session"][];
|
||||
};
|
||||
FieldError: {
|
||||
field: string;
|
||||
code: string;
|
||||
};
|
||||
Problem: {
|
||||
/** @default about:blank */
|
||||
@@ -109,6 +373,8 @@ export interface components {
|
||||
params?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** @description Ошибки отдельных полей запроса */
|
||||
errors?: components["schemas"]["FieldError"][];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
@@ -199,4 +465,232 @@ export interface operations {
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
login: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["LoginRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Успешный вход */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AuthTokens"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
refreshTokens: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
pipu_rt?: string;
|
||||
};
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RefreshRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Новая пара токенов */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AuthTokens"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
logout: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Сессия завершена, cookie `pipu_rt` очищена */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
logoutAll: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Все сессии пользователя завершены */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getMe: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Профиль */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Me"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
updateMe: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UpdateMeRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Обновлённый профиль */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Me"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
changePassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChangePasswordRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Пароль изменён, новая пара токенов */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AuthTokens"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
setBootstrapCredentials: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BootstrapCredentialsRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Учётные данные заменены, новая пара токенов */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AuthTokens"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
listMySessions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Список сессий, новые сверху */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SessionList"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
revokeMySession: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Сессия завершена */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"common": {
|
||||
"internal": "Something went wrong on the server. Please try again",
|
||||
"bad_request": "Invalid request",
|
||||
"not_found": "Not found",
|
||||
"method_not_allowed": "This action is not supported"
|
||||
},
|
||||
"auth": {
|
||||
"invalid_credentials": "Wrong username or password",
|
||||
"too_many_attempts": "Too many sign-in attempts. Please wait a bit",
|
||||
"user_blocked": "This account is blocked. Contact your administrator",
|
||||
"unauthorized": "Sign in to continue",
|
||||
"token_expired": "Your session has expired, please sign in again",
|
||||
"token_invalid": "Your session is invalid, please sign in again",
|
||||
"token_revoked": "Your session has ended, please sign in again",
|
||||
"refresh_invalid": "Your session has ended, please sign in again",
|
||||
"refresh_reused": "Your session was ended for security reasons, please sign in again",
|
||||
"refresh_race": "Your session is being refreshed, please retry",
|
||||
"credentials_change_required": "Change the administrator username and password first",
|
||||
"password_change_required": "Change your temporary password first",
|
||||
"current_password_required": "Enter your current password",
|
||||
"current_password_invalid": "The current password is wrong",
|
||||
"bootstrap_not_required": "The administrator credentials have already been changed",
|
||||
"session_not_found": "Session not found"
|
||||
},
|
||||
"validation": {
|
||||
"password_too_short": "The password must be at least {{min}} characters",
|
||||
"password_too_long": "The password must be at most {{max}} characters",
|
||||
"password_same_as_username": "The password must not match the username",
|
||||
"password_too_common": "This password is too common — choose another one",
|
||||
"password_unchanged": "The new password must differ from the current one",
|
||||
"username_invalid": "Username: {{min}} to {{max}} characters — Latin letters, digits, \".\", \"_\", \"-\"",
|
||||
"username_taken": "This username is already taken",
|
||||
"locale_unsupported": "This language is not supported",
|
||||
"display_name_too_long": "The name must be at most {{max}} characters",
|
||||
"ui_prefs_invalid": "Invalid setting value",
|
||||
"client_type_invalid": "Unknown app type"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"common": {
|
||||
"internal": "Что-то пошло не так на сервере. Попробуйте ещё раз",
|
||||
"bad_request": "Некорректный запрос",
|
||||
"not_found": "Не найдено",
|
||||
"method_not_allowed": "Действие не поддерживается"
|
||||
},
|
||||
"auth": {
|
||||
"invalid_credentials": "Неверный логин или пароль",
|
||||
"too_many_attempts": "Слишком много попыток входа. Подождите немного",
|
||||
"user_blocked": "Учётная запись заблокирована. Обратитесь к администратору",
|
||||
"unauthorized": "Войдите, чтобы продолжить",
|
||||
"token_expired": "Сессия истекла, войдите снова",
|
||||
"token_invalid": "Сессия недействительна, войдите снова",
|
||||
"token_revoked": "Сессия завершена, войдите снова",
|
||||
"refresh_invalid": "Сессия завершена, войдите снова",
|
||||
"refresh_reused": "Сессия завершена из соображений безопасности, войдите снова",
|
||||
"refresh_race": "Сессия обновляется, повторите действие",
|
||||
"credentials_change_required": "Сначала смените логин и пароль администратора",
|
||||
"password_change_required": "Сначала смените временный пароль",
|
||||
"current_password_required": "Введите текущий пароль",
|
||||
"current_password_invalid": "Текущий пароль указан неверно",
|
||||
"bootstrap_not_required": "Учётные данные администратора уже изменены",
|
||||
"session_not_found": "Сессия не найдена"
|
||||
},
|
||||
"validation": {
|
||||
"password_too_short": "Пароль должен быть не короче {{min}} символов",
|
||||
"password_too_long": "Пароль должен быть не длиннее {{max}} символов",
|
||||
"password_same_as_username": "Пароль не должен совпадать с логином",
|
||||
"password_too_common": "Этот пароль слишком распространён — придумайте другой",
|
||||
"password_unchanged": "Новый пароль должен отличаться от текущего",
|
||||
"username_invalid": "Логин: от {{min}} до {{max}} символов — латинские буквы, цифры, «.», «_», «-»",
|
||||
"username_taken": "Этот логин уже занят",
|
||||
"locale_unsupported": "Этот язык не поддерживается",
|
||||
"display_name_too_long": "Имя должно быть не длиннее {{max}} символов",
|
||||
"ui_prefs_invalid": "Недопустимое значение настройки",
|
||||
"client_type_invalid": "Неизвестный тип приложения"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import i18next, { type i18n as I18n } from 'i18next';
|
||||
|
||||
import enCommon from '../locales/en/common.json';
|
||||
import enErrors from '../locales/en/errors.json';
|
||||
import ruCommon from '../locales/ru/common.json';
|
||||
import ruErrors from '../locales/ru/errors.json';
|
||||
|
||||
/** Поддерживаемые языки. Новый язык = папка в locales/ + строка здесь. */
|
||||
export const SUPPORTED_LOCALES = ['ru', 'en'] as const;
|
||||
@@ -9,8 +11,8 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
|
||||
export const resources = {
|
||||
ru: { common: ruCommon },
|
||||
en: { common: enCommon },
|
||||
ru: { common: ruCommon, errors: ruErrors },
|
||||
en: { common: enCommon, errors: enErrors },
|
||||
} as const;
|
||||
|
||||
declare module 'i18next' {
|
||||
@@ -38,7 +40,7 @@ export async function createI18n(locale: Locale): Promise<I18n> {
|
||||
fallbackLng: DEFAULT_LOCALE,
|
||||
supportedLngs: [...SUPPORTED_LOCALES],
|
||||
defaultNS: 'common',
|
||||
ns: ['common'],
|
||||
ns: ['common', 'errors'],
|
||||
resources,
|
||||
interpolation: { escapeValue: false },
|
||||
returnNull: false,
|
||||
|
||||
Reference in new issue
Block a user