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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:12:25 +03:00
1 parent b6def39e29
commit ed4a492dba
37 files changed
+5826 -49

No files matched your search

+494
View File
@@ -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"];
};
};
}