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

+364 -1
View File
@@ -9,11 +9,16 @@ servers:
- url: /
tags:
- name: system
- name: auth
- name: me
security:
- bearerAuth: []
paths:
/healthz:
get:
operationId: getHealthz
tags: [system]
security: []
summary: Живость процесса
responses:
"200":
@@ -26,6 +31,7 @@ paths:
get:
operationId: getReadyz
tags: [system]
security: []
summary: Готовность обслуживать запросы (зависимости доступны)
responses:
"200":
@@ -44,6 +50,7 @@ paths:
get:
operationId: getServerInfo
tags: [system]
security: []
summary: Сведения о сервере для клиентов
description: |
Вызывается без аутентификации: веб и Android проверяют версию сервера,
@@ -57,7 +64,199 @@ paths:
$ref: "#/components/schemas/ServerInfo"
default:
$ref: "#/components/responses/Problem"
/api/v1/auth/login:
post:
operationId: login
tags: [auth]
security: []
summary: Вход по логину и паролю
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`.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/LoginRequest"
responses:
"200":
description: Успешный вход
content:
application/json:
schema:
$ref: "#/components/schemas/AuthTokens"
default:
$ref: "#/components/responses/Problem"
/api/v1/auth/refresh:
post:
operationId: refreshTokens
tags: [auth]
security: []
summary: Ротация refresh-токена
description: |
Refresh-токен берётся из cookie `pipu_rt` (веб) или из тела (Android). Старый токен
становится использованным; его повторное предъявление отзывает всю семью токенов.
parameters:
- name: pipu_rt
in: cookie
required: false
schema:
type: string
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/RefreshRequest"
responses:
"200":
description: Новая пара токенов
content:
application/json:
schema:
$ref: "#/components/schemas/AuthTokens"
default:
$ref: "#/components/responses/Problem"
/api/v1/auth/logout:
post:
operationId: logout
tags: [auth]
summary: Выход — отзыв текущей сессии
responses:
"204":
description: Сессия завершена, cookie `pipu_rt` очищена
default:
$ref: "#/components/responses/Problem"
/api/v1/auth/logout-all:
post:
operationId: logoutAll
tags: [auth]
summary: Выход на всех устройствах
responses:
"204":
description: Все сессии пользователя завершены
default:
$ref: "#/components/responses/Problem"
/api/v1/me:
get:
operationId: getMe
tags: [me]
summary: Текущий пользователь
responses:
"200":
description: Профиль
content:
application/json:
schema:
$ref: "#/components/schemas/Me"
default:
$ref: "#/components/responses/Problem"
patch:
operationId: updateMe
tags: [me]
summary: Изменить имя, язык, настройки интерфейса
description: Поля `ui_prefs` сливаются с сохранёнными; не переданные поля не меняются.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateMeRequest"
responses:
"200":
description: Обновлённый профиль
content:
application/json:
schema:
$ref: "#/components/schemas/Me"
default:
$ref: "#/components/responses/Problem"
/api/v1/me/password:
post:
operationId: changePassword
tags: [me]
summary: Сменить пароль
description: |
`current_password` обязателен, кроме случая `must_change_password` (вход по временному паролю).
Все сессии отзываются; текущему устройству выдаётся новая пара токенов.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ChangePasswordRequest"
responses:
"200":
description: Пароль изменён, новая пара токенов
content:
application/json:
schema:
$ref: "#/components/schemas/AuthTokens"
default:
$ref: "#/components/responses/Problem"
/api/v1/me/bootstrap-credentials:
post:
operationId: setBootstrapCredentials
tags: [me]
summary: Первый запуск — новые логин и пароль администратора
description: |
Только при `must_change_credentials`. Пароль должен отличаться от текущего.
Все прочие сессии отзываются, выдаётся новая пара токенов.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BootstrapCredentialsRequest"
responses:
"200":
description: Учётные данные заменены, новая пара токенов
content:
application/json:
schema:
$ref: "#/components/schemas/AuthTokens"
default:
$ref: "#/components/responses/Problem"
/api/v1/me/sessions:
get:
operationId: listMySessions
tags: [me]
summary: Активные сессии (устройства)
responses:
"200":
description: Список сессий, новые сверху
content:
application/json:
schema:
$ref: "#/components/schemas/SessionList"
default:
$ref: "#/components/responses/Problem"
/api/v1/me/sessions/{id}:
delete:
operationId: revokeMySession
tags: [me]
summary: Завершить сессию на устройстве
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"204":
description: Сессия завершена
default:
$ref: "#/components/responses/Problem"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
responses:
Problem:
description: Ошибка (RFC 9457)
@@ -88,7 +287,7 @@ components:
description: "`ok` или `fail` (причина — только в логе сервиса)"
ServerInfo:
type: object
required: [name, version, api_version, supported_locales, min_android_version_code]
required: [name, version, api_version, supported_locales, min_android_version_code, bootstrap_required]
properties:
name:
type: string
@@ -109,6 +308,165 @@ components:
type: integer
description: Минимальный versionCode Android-приложения, которое поддерживает сервер
example: 10099
bootstrap_required:
type: boolean
description: Первый вход администратора ещё не завершён — клиент может показать подсказку
Role:
type: string
enum: [admin, moderator, user]
ClientType:
type: string
enum: [web, android]
LoginRequest:
type: object
required: [username, password, client_type]
properties:
username:
type: string
password:
type: string
client_type:
$ref: "#/components/schemas/ClientType"
device_name:
type: string
maxLength: 100
description: Например «Chrome / Windows», «Pixel 8»
RefreshRequest:
type: object
properties:
refresh_token:
type: string
description: Только Android; веб передаёт токен в cookie
AuthTokens:
type: object
required: [access_token, token_type, expires_in, user]
properties:
access_token:
type: string
token_type:
type: string
enum: [Bearer]
expires_in:
type: integer
description: Срок жизни access-токена, секунды
refresh_token:
type: string
description: "Только для `client_type: android`"
user:
$ref: "#/components/schemas/Me"
UiPrefs:
type: object
properties:
theme:
type: string
enum: [dark, light, system]
wave_variant:
type: string
enum: [orb, liquid]
web_player:
type: string
enum: [bar, island]
mobile_player:
type: string
enum: [cover, wave]
ambient:
type: boolean
crossfade_s:
type: integer
minimum: 0
maximum: 6
normalize:
type: boolean
Me:
type: object
required: [id, username, display_name, role, locale, ui_prefs, must_change_password, must_change_credentials, created_at]
properties:
id:
type: string
format: uuid
username:
type: string
display_name:
type: string
nullable: true
role:
$ref: "#/components/schemas/Role"
locale:
type: string
ui_prefs:
$ref: "#/components/schemas/UiPrefs"
must_change_password:
type: boolean
must_change_credentials:
type: boolean
created_at:
type: string
format: date-time
UpdateMeRequest:
type: object
properties:
display_name:
type: string
maxLength: 64
description: Пустая строка — сбросить имя
locale:
type: string
ui_prefs:
$ref: "#/components/schemas/UiPrefs"
ChangePasswordRequest:
type: object
required: [new_password]
properties:
current_password:
type: string
new_password:
type: string
BootstrapCredentialsRequest:
type: object
required: [new_username, new_password]
properties:
new_username:
type: string
new_password:
type: string
Session:
type: object
required: [id, device_name, client_type, created_at, last_used_at, current]
properties:
id:
type: string
format: uuid
device_name:
type: string
nullable: true
client_type:
$ref: "#/components/schemas/ClientType"
created_at:
type: string
format: date-time
description: Время входа
last_used_at:
type: string
format: date-time
current:
type: boolean
description: Сессия, из которой сделан запрос
SessionList:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/Session"
FieldError:
type: object
required: [field, code]
properties:
field:
type: string
code:
type: string
Problem:
type: object
required: [status, code]
@@ -127,3 +485,8 @@ components:
params:
type: object
additionalProperties: true
errors:
type: array
description: Ошибки отдельных полей запроса
items:
$ref: "#/components/schemas/FieldError"