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
+364
-1
@@ -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"
|
||||
Reference in new issue
Block a user