diff --git a/api/openapi.yaml b/api/openapi.yaml index a12b289..40c5fb3 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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" diff --git a/deploy/.env.example b/deploy/.env.example index 417ef38..920ca2f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -20,6 +20,11 @@ HTTP_PORT=80 POSTGRES_PASSWORD= MEILI_MASTER_KEY= INTERNAL_TOKEN= +# Ключ подписи access-токенов (Ed25519, base64 от 32 случайных байт). install.sh и update.sh +# создают его, если строки нет. Смена ключа разлогинит всех на ≤15 мин — старый можно оставить +# в JWT_PREVIOUS_KEYS (через запятую), пока не истекут выданные им токены. +JWT_PRIVATE_KEY= +#JWT_PREVIOUS_KEYS= # --- Первый администратор (необязательно; иначе пароль генерируется и выводится в лог api один раз) --- #BOOTSTRAP_ADMIN_USERNAME=admin diff --git a/deploy/install.sh b/deploy/install.sh index 824209f..567d339 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -66,6 +66,13 @@ if [ ! -f .env ]; then echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)" fi +# Ключ подписи токенов (M1): создаётся один раз, в том числе в .env, сделанном до его появления. +if ! grep -q '^JWT_PRIVATE_KEY=.' .env; then + sed -i '/^JWT_PRIVATE_KEY=$/d' .env + echo "JWT_PRIVATE_KEY=$(head -c 32 /dev/urandom | base64)" >> .env + echo "→ в .env добавлен JWT_PRIVATE_KEY" +fi + # LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env if [ -n "$LIBRARY_HOST" ]; then if grep -q '^LIBRARY_HOST=' .env; then diff --git a/deploy/update.sh b/deploy/update.sh index 54297e6..b35042c 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -16,6 +16,13 @@ cd "$PIPU_DEPLOY_DIR" [ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; } +# Ключ подписи токенов (M1): создаётся один раз, в том числе в .env, сделанном до его появления. +if ! grep -q '^JWT_PRIVATE_KEY=.' .env; then + sed -i '/^JWT_PRIVATE_KEY=$/d' .env + echo "JWT_PRIVATE_KEY=$(head -c 32 /dev/urandom | base64)" >> .env + echo "→ в .env добавлен JWT_PRIVATE_KEY" +fi + REF="${1:-}" git fetch --tags --prune --quiet origin diff --git a/docs/auth-and-roles.md b/docs/auth-and-roles.md index ef77527..6f5cc4b 100644 --- a/docs/auth-and-roles.md +++ b/docs/auth-and-roles.md @@ -55,7 +55,7 @@ - argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе. - Политика: ≥ 8 символов для `user`, ≥ 12 для `moderator`/`admin`; не совпадает с логином; проверка по - встроенному списку 10k самых частых паролей. + встроенному списку частых паролей (`internal/auth/common_passwords.txt`; пока ~150 записей, цель — 10k). - Временный пароль при создании пользователя и при сбросе генерирует сервер: 12 символов, показывается **один раз**; у пользователя ставится `must_change_password = true`. - Защита от перебора: после 5 неудачных попыток — `locked_until = now() + 5 мин` (растёт экспоненциально до 1 ч), @@ -73,10 +73,16 @@ | Android | в памяти | `expo-secure-store` | - **Ротация**: каждый `/auth/refresh` помечает старый токен `used_at` и выдаёт новый в той же `family_id`. - Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи. + Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи + (`401 auth.refresh_reused`). Исключение — первые 10 с после ротации: так бывает, когда две вкладки + обновляют токен одновременно; ответ `401 auth.refresh_race` без отзыва, клиент повторяет запрос + с уже новой cookie. - **Мгновенный отзыв** (блокировка, смена роли, смена пароля): у пользователя инкрементируется `token_version` (в Redis-кэше), middleware сравнивает с `ver` в access-токене. -- Ключи подписи: `JWT_PRIVATE_KEY` из env / файла; поддержка `kid` для ротации ключей. +- Ключи подписи: `JWT_PRIVATE_KEY` (base64 seed Ed25519) из env; `kid` = первые 8 байт SHA-256 публичного ключа, + прежние ключи для проверки — `JWT_PREVIOUS_KEYS`. +- Смена пароля и учётных данных отзывает все сессии и сразу выдаёт новую пару текущему устройству — + пользователь остаётся в системе, остальные устройства разлогиниваются. - CSRF: refresh-cookie `SameSite=Strict` и путь только `/api/v1/auth`; остальные запросы — Bearer-заголовок, поэтому CSRF к ним неприменим. diff --git a/docs/data-model.md b/docs/data-model.md index 1640ffd..45c5878 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -38,6 +38,7 @@ CREATE TABLE refresh_tokens ( id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, family_id uuid NOT NULL, -- цепочка ротаций одного входа + family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней) token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена device_name text, -- "Chrome / Windows", "Pixel 8" client_type text NOT NULL, -- 'web' | 'android' diff --git a/docs/deployment.md b/docs/deployment.md index deaeaaf..0cc78be 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -67,7 +67,10 @@ | `DATABASE_URL` | `postgres://pipu:…@postgres:5432/pipu` | | | `REDIS_URL` | `redis://redis:6379/0` | | | `MEILI_URL`, `MEILI_MASTER_KEY` | | | -| `JWT_PRIVATE_KEY_FILE` | `/run/secrets/jwt_ed25519.pem` | Генерируется `install.sh` | +| `JWT_PRIVATE_KEY` | base64 от 32 байт (seed Ed25519) | Подпись access-токенов. Создают `install.sh` / `update.sh`, если его нет; пусто — случайный ключ до перезапуска | +| `JWT_PREVIOUS_KEYS` | пусто | Прежние ключи через запятую — токены, подписанные ими, ещё принимаются (ротация) | +| `ACCESS_TOKEN_TTL` | `15m` | Срок access-токена | +| `COOKIE_SECURE` | `true` | Флаг `Secure` у refresh-cookie; `false` — только для разработки по http не на localhost | | `STREAM_SIGNING_KEY` | 32 байта base64 | HMAC подписанных ссылок | | `INTERNAL_TOKEN` | случайный | Межсервисная аутентификация | | `BOOTSTRAP_ADMIN_USERNAME` | `admin` | | diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index b94cbe1..d6fadfc 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -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; 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"]; + }; + }; } diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json new file mode 100644 index 0000000..551a677 --- /dev/null +++ b/packages/i18n/locales/en/errors.json @@ -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" + } +} diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json new file mode 100644 index 0000000..2389350 --- /dev/null +++ b/packages/i18n/locales/ru/errors.json @@ -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": "Неизвестный тип приложения" + } +} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 43c02a0..1447977 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -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 { fallbackLng: DEFAULT_LOCALE, supportedLngs: [...SUPPORTED_LOCALES], defaultNS: 'common', - ns: ['common'], + ns: ['common', 'errors'], resources, interpolation: { escapeValue: false }, returnNull: false, diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index 278d767..c9836f7 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -3,13 +3,17 @@ package main import ( "context" + "crypto/ed25519" + "fmt" "log/slog" "os" "os/signal" "syscall" + "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" + "pipu/api/internal/auth" "pipu/api/internal/buildinfo" "pipu/api/internal/platform/config" "pipu/api/internal/platform/db" @@ -56,16 +60,69 @@ func run() error { rdb := redis.NewClient(redisOpts) defer func() { _ = rdb.Close() }() + authSvc, err := newAuthService(cfg, pool, rdb, log) + if err != nil { + return err + } + if err := authSvc.Bootstrap(ctx); err != nil { + return err + } + sys := system.NewHandler(system.Info{ Version: buildinfo.Version, SupportedLocales: cfg.SupportedLocales, MinAndroidVersionCode: cfg.MinAndroidVersionCode, + BootstrapRequired: authSvc.BootstrapRequired, }, log, map[string]system.Checker{ "postgres": system.CheckerFunc(pool.Ping), "redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }), }) - srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{Log: log, System: sys})) + srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{ + Log: log, + System: sys, + Auth: auth.NewHandler(authSvc, cfg.CookieSecure), + AuthService: authSvc, + })) log.Info("starting", "version", buildinfo.Version) return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout) } + +func newAuthService(cfg config.API, pool *pgxpool.Pool, rdb *redis.Client, log *slog.Logger) (*auth.Service, error) { + var ( + key ed25519.PrivateKey + err error + ) + if cfg.JWTPrivateKey == "" { + log.Warn("JWT_PRIVATE_KEY is not set: using a random key, access tokens will not survive a restart") + key, err = auth.GenerateKey() + } else { + key, err = auth.ParsePrivateKey(cfg.JWTPrivateKey) + } + if err != nil { + return nil, err + } + var previous []ed25519.PrivateKey + for _, s := range cfg.JWTPreviousKeys { + if s == "" { + continue + } + k, err := auth.ParsePrivateKey(s) + if err != nil { + return nil, fmt.Errorf("JWT_PREVIOUS_KEYS: %w", err) + } + previous = append(previous, k) + } + clock := auth.SystemClock{} + acfg := auth.DefaultConfig() + acfg.BootstrapUsername = cfg.BootstrapAdminUsername + acfg.BootstrapPassword = cfg.BootstrapAdminPassword + acfg.SupportedLocales = cfg.SupportedLocales + return auth.NewService( + auth.NewPgStore(pool), + auth.NewHasher(auth.DefaultArgon2Params), + auth.NewTokenIssuer(key, previous, cfg.AccessTokenTTL, clock.Now), + auth.NewRedisLimiter(rdb), + clock, auth.UUIDv7{}, log, acfg, + ) +} diff --git a/services/api/go.mod b/services/api/go.mod index 87d5f98..9506bfc 100644 --- a/services/api/go.mod +++ b/services/api/go.mod @@ -8,14 +8,18 @@ require ( github.com/caarlos0/env/v11 v11.3.1 github.com/getkin/kin-openapi v0.127.0 github.com/go-chi/chi/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.5 github.com/oapi-codegen/runtime v1.1.1 github.com/pressly/goose/v3 v3.24.3 github.com/redis/go-redis/v9 v9.10.0 github.com/stretchr/testify v1.10.0 + golang.org/x/crypto v0.38.0 ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -37,9 +41,9 @@ require ( github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.38.0 // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.25.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/services/api/go.sum b/services/api/go.sum index 187e7e3..2a0dd5f 100644 --- a/services/api/go.sum +++ b/services/api/go.sum @@ -1,3 +1,7 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -33,6 +37,8 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -65,6 +71,7 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -120,6 +127,7 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg= github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index 6eaa730..ed03545 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -14,10 +14,28 @@ import ( "net/url" "path" "strings" + "time" "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +const ( + BearerAuthScopes = "bearerAuth.Scopes" +) + +// Defines values for AuthTokensTokenType. +const ( + Bearer AuthTokensTokenType = "Bearer" +) + +// Defines values for ClientType. +const ( + Android ClientType = "android" + Web ClientType = "web" ) // Defines values for HealthStatusStatus. @@ -31,6 +49,75 @@ const ( ReadyStatusStatusOk ReadyStatusStatus = "ok" ) +// Defines values for Role. +const ( + Admin Role = "admin" + Moderator Role = "moderator" + User Role = "user" +) + +// Defines values for UiPrefsMobilePlayer. +const ( + Cover UiPrefsMobilePlayer = "cover" + Wave UiPrefsMobilePlayer = "wave" +) + +// Defines values for UiPrefsTheme. +const ( + Dark UiPrefsTheme = "dark" + Light UiPrefsTheme = "light" + System UiPrefsTheme = "system" +) + +// Defines values for UiPrefsWaveVariant. +const ( + Liquid UiPrefsWaveVariant = "liquid" + Orb UiPrefsWaveVariant = "orb" +) + +// Defines values for UiPrefsWebPlayer. +const ( + Bar UiPrefsWebPlayer = "bar" + Island UiPrefsWebPlayer = "island" +) + +// AuthTokens defines model for AuthTokens. +type AuthTokens struct { + AccessToken string `json:"access_token"` + + // ExpiresIn Срок жизни access-токена, секунды + ExpiresIn int `json:"expires_in"` + + // RefreshToken Только для `client_type: android` + RefreshToken *string `json:"refresh_token,omitempty"` + TokenType AuthTokensTokenType `json:"token_type"` + User Me `json:"user"` +} + +// AuthTokensTokenType defines model for AuthTokens.TokenType. +type AuthTokensTokenType string + +// BootstrapCredentialsRequest defines model for BootstrapCredentialsRequest. +type BootstrapCredentialsRequest struct { + NewPassword string `json:"new_password"` + NewUsername string `json:"new_username"` +} + +// ChangePasswordRequest defines model for ChangePasswordRequest. +type ChangePasswordRequest struct { + CurrentPassword *string `json:"current_password,omitempty"` + NewPassword string `json:"new_password"` +} + +// ClientType defines model for ClientType. +type ClientType string + +// FieldError defines model for FieldError. +type FieldError struct { + Code string `json:"code"` + Field string `json:"field"` +} + // HealthStatus defines model for HealthStatus. type HealthStatus struct { Status HealthStatusStatus `json:"status"` @@ -39,13 +126,39 @@ type HealthStatus struct { // HealthStatusStatus defines model for HealthStatus.Status. type HealthStatusStatus string +// LoginRequest defines model for LoginRequest. +type LoginRequest struct { + ClientType ClientType `json:"client_type"` + + // DeviceName Например «Chrome / Windows», «Pixel 8» + DeviceName *string `json:"device_name,omitempty"` + Password string `json:"password"` + Username string `json:"username"` +} + +// Me defines model for Me. +type Me struct { + CreatedAt time.Time `json:"created_at"` + DisplayName *string `json:"display_name"` + Id openapi_types.UUID `json:"id"` + Locale string `json:"locale"` + MustChangeCredentials bool `json:"must_change_credentials"` + MustChangePassword bool `json:"must_change_password"` + Role Role `json:"role"` + UiPrefs UiPrefs `json:"ui_prefs"` + Username string `json:"username"` +} + // Problem defines model for Problem. type Problem struct { // Code Машинный код ошибки для i18n, например `auth.invalid_credentials` Code string `json:"code"` // Detail Пояснение для логов (не для показа пользователю) - Detail *string `json:"detail,omitempty"` + Detail *string `json:"detail,omitempty"` + + // Errors Ошибки отдельных полей запроса + Errors *[]FieldError `json:"errors,omitempty"` Params *map[string]interface{} `json:"params,omitempty"` Status int `json:"status"` Type *string `json:"type,omitempty"` @@ -61,10 +174,22 @@ type ReadyStatus struct { // ReadyStatusStatus defines model for ReadyStatus.Status. type ReadyStatusStatus string +// RefreshRequest defines model for RefreshRequest. +type RefreshRequest struct { + // RefreshToken Только Android; веб передаёт токен в cookie + RefreshToken *string `json:"refresh_token,omitempty"` +} + +// Role defines model for Role. +type Role string + // ServerInfo defines model for ServerInfo. type ServerInfo struct { ApiVersion string `json:"api_version"` + // BootstrapRequired Первый вход администратора ещё не завершён — клиент может показать подсказку + BootstrapRequired bool `json:"bootstrap_required"` + // MinAndroidVersionCode Минимальный versionCode Android-приложения, которое поддерживает сервер MinAndroidVersionCode int `json:"min_android_version_code"` Name string `json:"name"` @@ -74,8 +199,108 @@ type ServerInfo struct { Version string `json:"version"` } +// Session defines model for Session. +type Session struct { + ClientType ClientType `json:"client_type"` + + // CreatedAt Время входа + CreatedAt time.Time `json:"created_at"` + + // Current Сессия, из которой сделан запрос + Current bool `json:"current"` + DeviceName *string `json:"device_name"` + Id openapi_types.UUID `json:"id"` + LastUsedAt time.Time `json:"last_used_at"` +} + +// SessionList defines model for SessionList. +type SessionList struct { + Items []Session `json:"items"` +} + +// UiPrefs defines model for UiPrefs. +type UiPrefs struct { + Ambient *bool `json:"ambient,omitempty"` + CrossfadeS *int `json:"crossfade_s,omitempty"` + MobilePlayer *UiPrefsMobilePlayer `json:"mobile_player,omitempty"` + Normalize *bool `json:"normalize,omitempty"` + Theme *UiPrefsTheme `json:"theme,omitempty"` + WaveVariant *UiPrefsWaveVariant `json:"wave_variant,omitempty"` + WebPlayer *UiPrefsWebPlayer `json:"web_player,omitempty"` +} + +// UiPrefsMobilePlayer defines model for UiPrefs.MobilePlayer. +type UiPrefsMobilePlayer string + +// UiPrefsTheme defines model for UiPrefs.Theme. +type UiPrefsTheme string + +// UiPrefsWaveVariant defines model for UiPrefs.WaveVariant. +type UiPrefsWaveVariant string + +// UiPrefsWebPlayer defines model for UiPrefs.WebPlayer. +type UiPrefsWebPlayer string + +// UpdateMeRequest defines model for UpdateMeRequest. +type UpdateMeRequest struct { + // DisplayName Пустая строка — сбросить имя + DisplayName *string `json:"display_name,omitempty"` + Locale *string `json:"locale,omitempty"` + UiPrefs *UiPrefs `json:"ui_prefs,omitempty"` +} + +// RefreshTokensParams defines parameters for RefreshTokens. +type RefreshTokensParams struct { + PipuRt *string `form:"pipu_rt,omitempty" json:"pipu_rt,omitempty"` +} + +// LoginJSONRequestBody defines body for Login for application/json ContentType. +type LoginJSONRequestBody = LoginRequest + +// RefreshTokensJSONRequestBody defines body for RefreshTokens for application/json ContentType. +type RefreshTokensJSONRequestBody = RefreshRequest + +// UpdateMeJSONRequestBody defines body for UpdateMe for application/json ContentType. +type UpdateMeJSONRequestBody = UpdateMeRequest + +// SetBootstrapCredentialsJSONRequestBody defines body for SetBootstrapCredentials for application/json ContentType. +type SetBootstrapCredentialsJSONRequestBody = BootstrapCredentialsRequest + +// ChangePasswordJSONRequestBody defines body for ChangePassword for application/json ContentType. +type ChangePasswordJSONRequestBody = ChangePasswordRequest + // ServerInterface represents all server handlers. type ServerInterface interface { + // Вход по логину и паролю + // (POST /api/v1/auth/login) + Login(w http.ResponseWriter, r *http.Request) + // Выход — отзыв текущей сессии + // (POST /api/v1/auth/logout) + Logout(w http.ResponseWriter, r *http.Request) + // Выход на всех устройствах + // (POST /api/v1/auth/logout-all) + LogoutAll(w http.ResponseWriter, r *http.Request) + // Ротация refresh-токена + // (POST /api/v1/auth/refresh) + RefreshTokens(w http.ResponseWriter, r *http.Request, params RefreshTokensParams) + // Текущий пользователь + // (GET /api/v1/me) + GetMe(w http.ResponseWriter, r *http.Request) + // Изменить имя, язык, настройки интерфейса + // (PATCH /api/v1/me) + UpdateMe(w http.ResponseWriter, r *http.Request) + // Первый запуск — новые логин и пароль администратора + // (POST /api/v1/me/bootstrap-credentials) + SetBootstrapCredentials(w http.ResponseWriter, r *http.Request) + // Сменить пароль + // (POST /api/v1/me/password) + ChangePassword(w http.ResponseWriter, r *http.Request) + // Активные сессии (устройства) + // (GET /api/v1/me/sessions) + ListMySessions(w http.ResponseWriter, r *http.Request) + // Завершить сессию на устройстве + // (DELETE /api/v1/me/sessions/{id}) + RevokeMySession(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) // Сведения о сервере для клиентов // (GET /api/v1/server-info) GetServerInfo(w http.ResponseWriter, r *http.Request) @@ -91,6 +316,66 @@ type ServerInterface interface { type Unimplemented struct{} +// Вход по логину и паролю +// (POST /api/v1/auth/login) +func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Выход — отзыв текущей сессии +// (POST /api/v1/auth/logout) +func (_ Unimplemented) Logout(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Выход на всех устройствах +// (POST /api/v1/auth/logout-all) +func (_ Unimplemented) LogoutAll(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Ротация refresh-токена +// (POST /api/v1/auth/refresh) +func (_ Unimplemented) RefreshTokens(w http.ResponseWriter, r *http.Request, params RefreshTokensParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Текущий пользователь +// (GET /api/v1/me) +func (_ Unimplemented) GetMe(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Изменить имя, язык, настройки интерфейса +// (PATCH /api/v1/me) +func (_ Unimplemented) UpdateMe(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Первый запуск — новые логин и пароль администратора +// (POST /api/v1/me/bootstrap-credentials) +func (_ Unimplemented) SetBootstrapCredentials(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Сменить пароль +// (POST /api/v1/me/password) +func (_ Unimplemented) ChangePassword(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Активные сессии (устройства) +// (GET /api/v1/me/sessions) +func (_ Unimplemented) ListMySessions(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Завершить сессию на устройстве +// (DELETE /api/v1/me/sessions/{id}) +func (_ Unimplemented) RevokeMySession(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + // Сведения о сервере для клиентов // (GET /api/v1/server-info) func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -118,6 +403,225 @@ type ServerInterfaceWrapper struct { type MiddlewareFunc func(http.Handler) http.Handler +// Login operation middleware +func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Login(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// Logout operation middleware +func (siw *ServerInterfaceWrapper) Logout(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Logout(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// LogoutAll operation middleware +func (siw *ServerInterfaceWrapper) LogoutAll(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.LogoutAll(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RefreshTokens operation middleware +func (siw *ServerInterfaceWrapper) RefreshTokens(w http.ResponseWriter, r *http.Request) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params RefreshTokensParams + + { + var cookie *http.Cookie + + if cookie, err = r.Cookie("pipu_rt"); err == nil { + var value string + err = runtime.BindStyledParameterWithOptions("simple", "pipu_rt", cookie.Value, &value, runtime.BindStyledParameterOptions{Explode: true, Required: false}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "pipu_rt", Err: err}) + return + } + params.PipuRt = &value + + } + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RefreshTokens(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetMe operation middleware +func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetMe(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateMe operation middleware +func (siw *ServerInterfaceWrapper) UpdateMe(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateMe(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SetBootstrapCredentials operation middleware +func (siw *ServerInterfaceWrapper) SetBootstrapCredentials(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SetBootstrapCredentials(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ChangePassword operation middleware +func (siw *ServerInterfaceWrapper) ChangePassword(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ChangePassword(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListMySessions operation middleware +func (siw *ServerInterfaceWrapper) ListMySessions(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListMySessions(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RevokeMySession operation middleware +func (siw *ServerInterfaceWrapper) RevokeMySession(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RevokeMySession(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetServerInfo operation middleware func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -273,6 +777,36 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl ErrorHandlerFunc: options.ErrorHandlerFunc, } + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/auth/login", wrapper.Login) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/auth/logout", wrapper.Logout) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/auth/logout-all", wrapper.LogoutAll) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/auth/refresh", wrapper.RefreshTokens) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/me", wrapper.GetMe) + }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/api/v1/me", wrapper.UpdateMe) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/me/bootstrap-credentials", wrapper.SetBootstrapCredentials) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/me/password", wrapper.ChangePassword) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/me/sessions", wrapper.ListMySessions) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/api/v1/me/sessions/{id}", wrapper.RevokeMySession) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/server-info", wrapper.GetServerInfo) }) @@ -288,6 +822,290 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl type ProblemApplicationProblemPlusJSONResponse Problem +type LoginRequestObject struct { + Body *LoginJSONRequestBody +} + +type LoginResponseObject interface { + VisitLoginResponse(w http.ResponseWriter) error +} + +type Login200JSONResponse AuthTokens + +func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type LogindefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response LogindefaultApplicationProblemPlusJSONResponse) VisitLoginResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type LogoutRequestObject struct { +} + +type LogoutResponseObject interface { + VisitLogoutResponse(w http.ResponseWriter) error +} + +type Logout204Response struct { +} + +func (response Logout204Response) VisitLogoutResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type LogoutdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response LogoutdefaultApplicationProblemPlusJSONResponse) VisitLogoutResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type LogoutAllRequestObject struct { +} + +type LogoutAllResponseObject interface { + VisitLogoutAllResponse(w http.ResponseWriter) error +} + +type LogoutAll204Response struct { +} + +func (response LogoutAll204Response) VisitLogoutAllResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type LogoutAlldefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response LogoutAlldefaultApplicationProblemPlusJSONResponse) VisitLogoutAllResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type RefreshTokensRequestObject struct { + Params RefreshTokensParams + Body *RefreshTokensJSONRequestBody +} + +type RefreshTokensResponseObject interface { + VisitRefreshTokensResponse(w http.ResponseWriter) error +} + +type RefreshTokens200JSONResponse AuthTokens + +func (response RefreshTokens200JSONResponse) VisitRefreshTokensResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type RefreshTokensdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response RefreshTokensdefaultApplicationProblemPlusJSONResponse) VisitRefreshTokensResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type GetMeRequestObject struct { +} + +type GetMeResponseObject interface { + VisitGetMeResponse(w http.ResponseWriter) error +} + +type GetMe200JSONResponse Me + +func (response GetMe200JSONResponse) VisitGetMeResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetMedefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response GetMedefaultApplicationProblemPlusJSONResponse) VisitGetMeResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type UpdateMeRequestObject struct { + Body *UpdateMeJSONRequestBody +} + +type UpdateMeResponseObject interface { + VisitUpdateMeResponse(w http.ResponseWriter) error +} + +type UpdateMe200JSONResponse Me + +func (response UpdateMe200JSONResponse) VisitUpdateMeResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateMedefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response UpdateMedefaultApplicationProblemPlusJSONResponse) VisitUpdateMeResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type SetBootstrapCredentialsRequestObject struct { + Body *SetBootstrapCredentialsJSONRequestBody +} + +type SetBootstrapCredentialsResponseObject interface { + VisitSetBootstrapCredentialsResponse(w http.ResponseWriter) error +} + +type SetBootstrapCredentials200JSONResponse AuthTokens + +func (response SetBootstrapCredentials200JSONResponse) VisitSetBootstrapCredentialsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type SetBootstrapCredentialsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response SetBootstrapCredentialsdefaultApplicationProblemPlusJSONResponse) VisitSetBootstrapCredentialsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type ChangePasswordRequestObject struct { + Body *ChangePasswordJSONRequestBody +} + +type ChangePasswordResponseObject interface { + VisitChangePasswordResponse(w http.ResponseWriter) error +} + +type ChangePassword200JSONResponse AuthTokens + +func (response ChangePassword200JSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ChangePassworddefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response ChangePassworddefaultApplicationProblemPlusJSONResponse) VisitChangePasswordResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type ListMySessionsRequestObject struct { +} + +type ListMySessionsResponseObject interface { + VisitListMySessionsResponse(w http.ResponseWriter) error +} + +type ListMySessions200JSONResponse SessionList + +func (response ListMySessions200JSONResponse) VisitListMySessionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ListMySessionsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response ListMySessionsdefaultApplicationProblemPlusJSONResponse) VisitListMySessionsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type RevokeMySessionRequestObject struct { + Id openapi_types.UUID `json:"id"` +} + +type RevokeMySessionResponseObject interface { + VisitRevokeMySessionResponse(w http.ResponseWriter) error +} + +type RevokeMySession204Response struct { +} + +func (response RevokeMySession204Response) VisitRevokeMySessionResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type RevokeMySessiondefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response RevokeMySessiondefaultApplicationProblemPlusJSONResponse) VisitRevokeMySessionResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type GetServerInfoRequestObject struct { } @@ -359,6 +1177,36 @@ func (response GetReadyz503JSONResponse) VisitGetReadyzResponse(w http.ResponseW // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Вход по логину и паролю + // (POST /api/v1/auth/login) + Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) + // Выход — отзыв текущей сессии + // (POST /api/v1/auth/logout) + Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) + // Выход на всех устройствах + // (POST /api/v1/auth/logout-all) + LogoutAll(ctx context.Context, request LogoutAllRequestObject) (LogoutAllResponseObject, error) + // Ротация refresh-токена + // (POST /api/v1/auth/refresh) + RefreshTokens(ctx context.Context, request RefreshTokensRequestObject) (RefreshTokensResponseObject, error) + // Текущий пользователь + // (GET /api/v1/me) + GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) + // Изменить имя, язык, настройки интерфейса + // (PATCH /api/v1/me) + UpdateMe(ctx context.Context, request UpdateMeRequestObject) (UpdateMeResponseObject, error) + // Первый запуск — новые логин и пароль администратора + // (POST /api/v1/me/bootstrap-credentials) + SetBootstrapCredentials(ctx context.Context, request SetBootstrapCredentialsRequestObject) (SetBootstrapCredentialsResponseObject, error) + // Сменить пароль + // (POST /api/v1/me/password) + ChangePassword(ctx context.Context, request ChangePasswordRequestObject) (ChangePasswordResponseObject, error) + // Активные сессии (устройства) + // (GET /api/v1/me/sessions) + ListMySessions(ctx context.Context, request ListMySessionsRequestObject) (ListMySessionsResponseObject, error) + // Завершить сессию на устройстве + // (DELETE /api/v1/me/sessions/{id}) + RevokeMySession(ctx context.Context, request RevokeMySessionRequestObject) (RevokeMySessionResponseObject, error) // Сведения о сервере для клиентов // (GET /api/v1/server-info) GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error) @@ -399,6 +1247,285 @@ type strictHandler struct { options StrictHTTPServerOptions } +// Login operation middleware +func (sh *strictHandler) Login(w http.ResponseWriter, r *http.Request) { + var request LoginRequestObject + + var body LoginJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Login(ctx, request.(LoginRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Login") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(LoginResponseObject); ok { + if err := validResponse.VisitLoginResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// Logout operation middleware +func (sh *strictHandler) Logout(w http.ResponseWriter, r *http.Request) { + var request LogoutRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Logout(ctx, request.(LogoutRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Logout") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(LogoutResponseObject); ok { + if err := validResponse.VisitLogoutResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// LogoutAll operation middleware +func (sh *strictHandler) LogoutAll(w http.ResponseWriter, r *http.Request) { + var request LogoutAllRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.LogoutAll(ctx, request.(LogoutAllRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "LogoutAll") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(LogoutAllResponseObject); ok { + if err := validResponse.VisitLogoutAllResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// RefreshTokens operation middleware +func (sh *strictHandler) RefreshTokens(w http.ResponseWriter, r *http.Request, params RefreshTokensParams) { + var request RefreshTokensRequestObject + + request.Params = params + + var body RefreshTokensJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.RefreshTokens(ctx, request.(RefreshTokensRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RefreshTokens") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(RefreshTokensResponseObject); ok { + if err := validResponse.VisitRefreshTokensResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetMe operation middleware +func (sh *strictHandler) GetMe(w http.ResponseWriter, r *http.Request) { + var request GetMeRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetMe(ctx, request.(GetMeRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetMe") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetMeResponseObject); ok { + if err := validResponse.VisitGetMeResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UpdateMe operation middleware +func (sh *strictHandler) UpdateMe(w http.ResponseWriter, r *http.Request) { + var request UpdateMeRequestObject + + var body UpdateMeJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UpdateMe(ctx, request.(UpdateMeRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateMe") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UpdateMeResponseObject); ok { + if err := validResponse.VisitUpdateMeResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// SetBootstrapCredentials operation middleware +func (sh *strictHandler) SetBootstrapCredentials(w http.ResponseWriter, r *http.Request) { + var request SetBootstrapCredentialsRequestObject + + var body SetBootstrapCredentialsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.SetBootstrapCredentials(ctx, request.(SetBootstrapCredentialsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SetBootstrapCredentials") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(SetBootstrapCredentialsResponseObject); ok { + if err := validResponse.VisitSetBootstrapCredentialsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ChangePassword operation middleware +func (sh *strictHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + var request ChangePasswordRequestObject + + var body ChangePasswordJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ChangePassword(ctx, request.(ChangePasswordRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ChangePassword") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ChangePasswordResponseObject); ok { + if err := validResponse.VisitChangePasswordResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ListMySessions operation middleware +func (sh *strictHandler) ListMySessions(w http.ResponseWriter, r *http.Request) { + var request ListMySessionsRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListMySessions(ctx, request.(ListMySessionsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListMySessions") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListMySessionsResponseObject); ok { + if err := validResponse.VisitListMySessionsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// RevokeMySession operation middleware +func (sh *strictHandler) RevokeMySession(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + var request RevokeMySessionRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.RevokeMySession(ctx, request.(RevokeMySessionRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RevokeMySession") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(RevokeMySessionResponseObject); ok { + if err := validResponse.VisitRevokeMySessionResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetServerInfo operation middleware func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) { var request GetServerInfoRequestObject @@ -474,28 +1601,64 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/7xW3W4bRRR+ldHARSw2tkOpoL6rKqC9QIoSxE0bJZPdSTLN/jE7GxEiS3GCWqSURioI", - "cYFa+gabYDdLHG9e4cwr8CTozO7aa2cTwkW5snf3zPn5vu+cOXvUDrww8LmvItrZo5JHYeBH3DwsymDd", - "5R7+tQNfcV/hXxaGrrCZEoHfCnOLj55GgY/fInuLewz/fSj5Bu3QD1oT/638a9Qq/Xa7XYs6PLKlCNEd", - "7VB4rX+EFE7gHBIyt/TFA3Lvk7ufNihaFsfR+0POXLW1rJiKzXMog5BLJfK8o/F77sce7TymwTZdsaja", - "DTnt0EhJ4W8al5J/GwvJHbQpTk3sgvWn3Fa0a1WBmI5kBw7H35kifofElDGCkT6CvwicQwZ9Atm4uJRA", - "H4b6mIiFz3yLwAgSuNT7kMIFDPQ+WWOx2moKf4e5wlm1JXe4rwRzozV6pQ7EUDHh1iTyBjJ9rHswggGM", - "IIVBGRaGkMGfkMEpmcOv4/eXkCH2cAZJ/jDUL+AMDSHRBzCAoX7ZqMshZJJ5BhTmOAITYO5iBSwlY14D", - "7YSr4pPwFd/kEr/lb7CoDRa7inYoWw9i1Vl3mb9Nb8mnlZNUR+sSZ87udSKyt7i9fUM9s1ivBdtrBFIY", - "QkrWNphw18hcTql+bqSQkL/3fyH6oMD0HDICpyURA6J7SDycQqp7kDTqaZ4i9w8YwJk+RG/6ANkhJh5S", - "hZ6MyJDKd9CHDFV4BknhH2WW6Z4+gJTeSEqlgTD+pmQOd27dS1aJYh36y1zucPnI3wiugs9CsbrDZSTy", - "scK/Y17o4vGdhTpgPOGvMt+RgXDKY6vXtmZqOuECEkND3qDFoQeBw8n93NF80Y/Iz7u8ffSxZVoZOTRA", - "D/IW6UPfIP4OUmwTGOiDCZ0DvU+tSQUL7fa9e1aN2H3m8elaQxHG814cCbuu5igOw0Aq7qy6gc3cHLfx", - "4cdUxhjWR+iF4l61xSZOihdMSraLzxXMZ3B7hYWgcvTxVG04ppe59w2XjWqZtN1caLb/tUdN0ZOw1hTx", - "dTXewPVVjWE0UehrdjDqQzjBXtXPCwksfb78Nbm/+IgsijAmXyHsTQK/mSbJ0ApSOCfYPdg1eKicmbNw", - "BCwU85jSJvcbT/y8DYc4fWFk2v+UzAUh99EMM84TazQJvDWzYGhuj0JxZmg4gR21WCiantN8gsAooQzK", - "k1wx9QqSJQFdixahaIfeababdyiOarVl9IA+WzsLrci04nyJ1SZXdQrQR3Cmj0qF6x5eFyc4gwgk+tBc", - "Dlhfqn9AqCDRzyCFtEMQGTghkJatNTWn9LF+iaPrdCywlzOIWk983TPmeDfm8BfjS7+YxjbBKPU9CRf6", - "CMfssSkCh+Oc/gnOMQCMyjPneL3p5yX4jRxtnExm33nk0A79kqvK6LKmV6aP2+0b1qX/tiZVotRtSm8N", - "rv2xUCCbxm2QXxnF1Vkfapx7ZSfDtvM8JndvFWS8OMxIHEXKNiNzGexGint0BV23tszm9n1FZlfQfViY", - "vEdop/bHOnDfoED1M6O3HslVNIvNr0ZaYx1eVo9Ach0AEpeOG+tfyi3eY/nVxaeu+lfI8TX7AhKe13wI", - "lzgGUWZ323f+t+ReQ99sU8Wuhdur7pkNe7xW1SU+wA1oZMQ8k/40qz/n9zucwmjCbQYnugdDfVjOk/z1", - "WbG1o90RmbslYI1aaWAWpuHx/R6NpUs7tEW7K2PjvWJHKA91V7r/BAAA//8arOezvw0AAA==", + "H4sIAAAAAAAC/8xaW28bxxX+K4ttHySUujhx0lhGHhwjt8JGBcltHmyDHHJH4sbL3c3uULZiCNAljpNK", + "teq0aPPQOHH6mBeKESOakijAv+DMX+gvKc6Z2Rs5pORWSvMkcTk8c+bcvu+c2Yd2LWiEgc99EdtzD+2I", + "x2Hgx5w+zEdB1eMN/LcW+IL7Av9lYei5NSbcwJ8J1YrffBwHPn4X1+q8wfC/X0d8yZ6zfzWTyZ9R38Yz", + "idy1tbWS7fC4FrkhirPnbHgmv4Au7EEPWtbEwnvXrSuX3/jtpI0r9c9R+rWmqN8K7nGfPoVREPJIuEpr", + "VqvxOC4L/Bo/i9WQ23N2LCLXX7bXSjZ/ELoRj8sufT2w/3O5Dn3oWfATdOEAjqFrKYFTchO/gA4cQ6tk", + "yQ3oQE9uwTHsy227lGzj+oIv8wj3ifhSxON6psnAVt9DHw7lDvSgb8E+HMpdq1LzXO6LMgqbs5jvRIHr", + "VDLp2SFIaFk9fmhzv9mw527b73AW8ci+a/hBM+bRaY65ycnQEf+k6UbcQYkFaxa2LRhSy882Dqof85rA", + "jd8JAhGLiIXXI+5wX7jMixf4J00ei2Hn+fx+OWRxfD+IHKPzcAFu5bMGNywY0L6wulSUbtL1ep35y3xe", + "LxmpZa0ZReimUzUds8Cg6XjVKDJuDTj8Pq/aJVsHitHv77ncc96NoiAynCNwuFH3JfzR6UqrZSUlx6T0", + "B5x5or4omGgaMjVOnyfHCe4ZzjCwqf6VabsbwbLrj/ZallunJULO2FSiVtwaLychN5DG30ALTuQ6dOEI", + "OnLdevnD9XoUNLg1Y33k+k5wP355WLJe/jDvPuCe9dbLQ7tkN9iDG9xfFnV77tLsrMFrYyPr7PGfi/1U", + "YqlgCJMZb3KD8SLOBHfKjAy7FEQN/M92mOBTwqUdhvR03Dj02GpqOL/peazqcXtORE1u+IHrFIQ3m65j", + "kusFNeaZA7fRjEW5RllcrmX1Jre2GgQeZ/7gYoPBcyujwDs1ZhZwDXrHLYcRX4pPW/8Hd56WvZJDySA5", + "rxZMrPVMDZTTZcRhRxuslPe4KUhy5MBcVAbS5J/QImg/hmO5DS8shD3Yt6CfAn43QUH30lt+yUKcLSRW", + "hTVFfdr1V5jnOnlljQDpcMFcz6DIt9CXu3IDjgnKu9BJtoVD6MOP0Ie2NYHfps9PCPhbcAAt9QFR+wAX", + "QktuQgcO5ZNJkw4cy25s0OFZ/tB9uQn7JGUHbSMf6U2gg2Y60Fboyw1o2SXbFbxxamzliv5aqheLIraq", + "ikvElAzmOC7qxLz5nA8L2Zl5PCvXw1wnKaoOX2JND5OXVYOmmKt6zL9nn7GkjwGSBc6c1VE4Uqvz2r0x", + "5xk0fyW4V7GgC4fQtSpLzPUq1oSKNPmYIrRl/Xv9bxYRvpSgtZP46BD3k+vQhi76ZNIcfQV/fwcdOJBb", + "KE1uYtBYyqfQJkkUBhhhP8E+9BOvK/kY/X25ITeha491Sg5Dcf/liDncOTOclhIrmq1PTHYksr4K072m", + "uMpVC88OexjraIIO7ENLPpWbVsaz0ei1ILjncmMEDaupq3RiCeY0iJo2AodHTATRME3NPLbIoxUefegv", + "BYaWInTLKzyKXdXl8AesEeJW9solk/OrCeMtZ4Y2lCGKIVUL2/KRqoYt2IcjisEu+lyuU4Xp418LOvJL", + "+dRSpUkFSEeuyy/kUzimgIUehjRaDgPsCPrwE3Qo1rICJjfljnqwLzf0w57cyo6Rx0fXL2tqmRy/PLK6", + "k85wBK2kjsELS//oeuDwxO9TuqQfKu3ooLslQgN90D4ejxTEqijXqRNrQ4uOkqZeR65TB6I9cWl29soV", + "UxOWwGrms9ANm1ONZuzWTL6Lm2EYRIh7CkTjwo9v21ETt/UxhNJKPNycDRTcXOwM2O0rcuEGGqFwNux+", + "F3njjzyazB/Tnp2+ND17aj3VbCDZtlQIYNMZx/jaGM6mIrHI4+SQ58S7i5xz0HJUNI4Qn3X2EDyejZnq", + "Bs7U/0NHbiiHlBAjDoqh+QK9RGANLSxQOXQ2ptBA63BuDJjFAhvbV+HjJiaZV6/YFxSsP7BhZr8xcXDD", + "NSFFmjNnojFJSA2l1OBZSJhJmYRkD5f0RtXVETDstVoUxPESc3iZ1jbYA7eBkPIm5Yn6f9ZUbRpB1fV4", + "GSm5mrYkUFQLVjjCz322wo3w46MHPfdTbtZI1HmjgG0OixDnPXe5jg6JV2PBG0bJuGV5hUUuU8dNeUJU", + "JQGfNEdMDu7zquEkVYbncGOP+SPYxbATQgzLm3wkfxhsFAehUm4RAWpRkSRUJDxTLG0D9jQ97ipw62JV", + "KDbZb15+tTbylXu44WMjlvBaM3LF6iKuViet0oDuWhOVSj69lyTv7z66ZesxJ7lfDfNSyXUhQjUzdTVL", + "GbYT7CEFkI81AC+8u3jLujb/oTXvhk3rJoLetAVfkzX7uAq60LMU10AEl9tJ0zMIRgEL3SkEhGXuT97x", + "FWFN2YZqmoKQ+7gM9VWKTU5b8JxY8yG1fxrvyXFOUItnWOhON5zpOzRbdAVhXKYrqp7DsQT+1kq23sqe", + "s1+fnp1+neYbok4mRpkzK5dmsFuc8YJlNeYNg1iY8Rf2rIni3PU+r1Ymk05vSz7W3EOz3CkTQbUqSCvK", + "kajc8ScqHwgR/t73Vq9aixgC/Kq1yBp80RX87UURuTVx1Zpnov52XtXKZCnhSIrMtdM20KoUCHZl+o4P", + "30BHO0e306o56aJSqrOBE2hRphzKnZIFLSTWLegh60LIJPQkhxxje4NAtkdCetDVrUkra9YTkr6ncfAo", + "46xKWZqH/4ggfMevXJ69ZI1s1qct+JZkteW2CgmLAukI81ed+URuKyNbcoM4Ma7HXos2q1x+7YoSL4Kg", + "3GD+apkJwRuhiCsqkrC20AXFh449p6aCtkIMHot3Amd1zJXGq11lFCaOa0VcQoinB7krlddmZ89t79wl", + "iOkm5V9yg9z2ReJD7TBFS3Sbbt4hVTl3VZOVM3vu9l0kkY0Gi1YVFdPdywlGUhqIcssqhuETTHK2HFNv", + "hiXwLoodzNegKfIJO+RL/H7IqpfHU7lCv5Rc4QxmrkUlsSu/VAv+ezvlLbOdzxLkkQdyG9oWjY16cos2", + "e6GqLemquvyzGGmKed5phrrmeWez1VeoQUGNUZOuYVvK7fM1FQ1gVEWRjyyF/Yp8E0yhKo9ON5IumaNr", + "/4KhnO/RqZ7KTbmBB0X6PxQlE2pwMZnWWVyljINAqWs4IR9xlnXKvmyXO74iM1h3acaTbEb5WjC5rr9H", + "Vy0qrn3lk7buRo51q6ymJ/JPchfaNDXUU8002jR+oUnlE3VzeSR38N/sSrMPbVPt1EbSZUaPDrngEZr+", + "oY3YaqcjGkXfbG2phMow01T77sWU44FB1ZquyP+fAvyNzpvdpAa2Bgx+/pX4O3J6S35ORW+YsFCLPDpx", + "FPte5oZ68j4XN7l9gcaku+dhI35L3P4zzDW5cx515vu08HYVsTEVuZ2cmRrcvktDc1Grm68T1NV90jNU", + "MMMO1cxKPtHZLTfwaV8+wjCAY/k0SW3oXtUTvdwcVH/ZSdTb1UuIr8ndROr0ULomndYFsZ3BRu5nJjwj", + "QuQZ7Olaepja9YUesJ9n5HwNB5ox5xvNkiV3qcz21JVVhlZqrN+l/ghh5TOEen2LUwiuQgbOpOO2qYEL", + "zBEtTOE1EhquWpURt3rEvbOuwKLbhkM1h1V4oZpHmhIrVOqriXxGVn6EPvYfijBoIz8mwCmyhxz6JPFa", + "smjerQf9Sv7xaUXShEqLXJjeKrmgqB/3AssvjfI/RssmxaNQSZC0qeglylY6g+nPI2kK1xxqWrolN6Cn", + "CDFto/TL96+F3nXspcj4VMpf6puzpzL4Kg92AbCHKZ2AAVqjZEFP9b0qzvVYAMu+6V6dSGKhKRrot+EI", + "m6NcZzSZ5dSpaXS1mJAoapgko/zzSbbiO1EXlGPmF69+WdlVqJzdBAwQcn6uZHpexJ9ckozPg1jN0eOR", + "7O6GG4ubq4vJsgu0cf52wGTk53BCnZAa/qSZ8KKULxZyQ/efj+TWedj1L9Cj+WdbF8tCCk6YOtDJsxl8", + "5qHrrKmq43HBhw2/wFeCezw1/YgOK2SinvVXbpKEWVbkW61TLo9U6/U/Dk/Ow+j/yAlVAZ2ZXT5RUwCD", + "6TtjLB/THf5UMh7XoT406tjOt8WqMu5BBw4saMktqq1I2brEHXuqm4LuXPrCQjed0+Zf4lC03NIf1BmK", + "Q/QSdv60/IjOuZm924HZnB+nt6xkADN0CQ5HKkQ16aQA/TOhE11Iqt/0EFPk42TePmmq6+9zkXvn4UJT", + "Pt3FnPFt6nqSuwHoF+3WOf82+dQt01e/Bu44crGX3LhR/NXpPddPx3XPH+glF2jowtu2o5vpz1WWqVfc", + "2+Mt9XcKuzRGT/ICCuyrYI6IM2d1rDUW1IoLNEb+jTGTLXQDY3zRihojOvMWnCQDzjdmX//ZlHsG+2oK", + "qkeMx3T90ct3eibF6YXBYwrtAfXH+fiv6v0GRL/M00iCiecmlUc9zr3vILetiTOab9IYKEWdinejt+8i", + "Tql6roCwGXn2nD1j43Mt62GCiFrmWil9QrOt3OcGt9furv0nAAD//ytPhyHuMgAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/auth/auth_test.go b/services/api/internal/auth/auth_test.go new file mode 100644 index 0000000..c4b4d5e --- /dev/null +++ b/services/api/internal/auth/auth_test.go @@ -0,0 +1,569 @@ +package auth_test + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "pipu/api/internal/auth" + "pipu/api/internal/auth/authtest" + "pipu/api/internal/server" + "pipu/api/internal/system" +) + +// Быстрые параметры argon2: тестам важна логика, а не стойкость. +var testArgon = auth.Argon2Params{MemoryKiB: 64, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32} + +type env struct { + t *testing.T + store *authtest.MemStore + clock *authtest.Clock + limiter *authtest.Limiter + hasher *auth.Hasher + svc *auth.Service + h http.Handler +} + +func newEnv(t *testing.T, mutate ...func(*auth.Config)) *env { + t.Helper() + e := &env{ + t: t, + store: authtest.NewMemStore(), + clock: &authtest.Clock{T: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)}, + limiter: &authtest.Limiter{}, + hasher: auth.NewHasher(testArgon), + } + key, err := auth.GenerateKey() + require.NoError(t, err) + cfg := auth.DefaultConfig() + cfg.BootstrapPassword = "" + for _, m := range mutate { + m(&cfg) + } + e.svc, err = auth.NewService(e.store, e.hasher, auth.NewTokenIssuer(key, nil, 15*time.Minute, e.clock.Now), + e.limiter, e.clock, auth.UUIDv7{}, nil, cfg) + require.NoError(t, err) + sys := system.NewHandler(system.Info{Version: "test", BootstrapRequired: e.svc.BootstrapRequired}, nil, nil) + e.h = server.NewRouter(server.Deps{System: sys, Auth: auth.NewHandler(e.svc, true), AuthService: e.svc}) + return e +} + +// addUser создаёт пользователя напрямую в хранилище. +func (e *env) addUser(username, password string, role auth.Role, mutate ...func(*auth.User)) auth.User { + e.t.Helper() + hash, err := e.hasher.Hash(password) + require.NoError(e.t, err) + u := auth.User{ID: uuid.New(), Username: username, PasswordHash: hash, Role: role, Locale: "ru", CreatedAt: e.clock.Now()} + for _, m := range mutate { + m(&u) + } + require.NoError(e.t, e.store.CreateUser(context.Background(), u, nil)) + return u +} + +type resp struct { + *httptest.ResponseRecorder + body map[string]any +} + +func (r resp) code() string { s, _ := r.body["code"].(string); return s } + +func (r resp) cookie(name string) *http.Cookie { + for _, c := range r.Result().Cookies() { + if c.Name == name { + return c + } + } + return nil +} + +type reqOpt func(*http.Request) + +func bearer(token string) reqOpt { + return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) } +} + +func withCookie(c *http.Cookie) reqOpt { return func(r *http.Request) { r.AddCookie(c) } } + +func (e *env) do(method, path string, body any, opts ...reqOpt) resp { + e.t.Helper() + var rd *bytes.Reader + if body != nil { + raw, err := json.Marshal(body) + require.NoError(e.t, err) + rd = bytes.NewReader(raw) + } else { + rd = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, rd) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.RemoteAddr = "192.0.2.10:5555" + for _, o := range opts { + o(req) + } + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + out := resp{ResponseRecorder: rec} + if rec.Body.Len() > 0 { + require.NoError(e.t, json.Unmarshal(rec.Body.Bytes(), &out.body), rec.Body.String()) + } + return out +} + +func (e *env) login(username, password, clientType string) resp { + e.t.Helper() + return e.do(http.MethodPost, "/api/v1/auth/login", map[string]any{ + "username": username, "password": password, "client_type": clientType, "device_name": "Test device", + }) +} + +func (e *env) mustLogin(username, password, clientType string) resp { + e.t.Helper() + r := e.login(username, password, clientType) + require.Equal(e.t, http.StatusOK, r.Code, r.Body.String()) + return r +} + +func access(r resp) string { return r.body["access_token"].(string) } + +func user(r resp) map[string]any { return r.body["user"].(map[string]any) } + +func TestBootstrapFlow(t *testing.T) { + e := newEnv(t) + ctx := context.Background() + require.NoError(t, e.svc.Bootstrap(ctx)) + require.NoError(t, e.svc.Bootstrap(ctx), "second bootstrap is a no-op") + require.Len(t, e.store.Users, 1) + + info := e.do(http.MethodGet, "/api/v1/server-info", nil) + assert.Equal(t, true, info.body["bootstrap_required"]) + + // Сгенерированный пароль неизвестен тесту — подменяем хэш известным паролем. + var admin auth.User + for _, u := range e.store.Users { + admin = u + } + assert.Equal(t, "admin", admin.Username) + assert.Equal(t, auth.RoleAdmin, admin.Role) + assert.True(t, admin.MustChangeCredentials) + admin.PasswordHash, _ = e.hasher.Hash("initial-secret-pass") + e.store.Users[admin.ID] = admin + + r := e.mustLogin("admin", "initial-secret-pass", "web") + assert.Equal(t, true, user(r)["must_change_credentials"]) + tok := access(r) + + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code) + blocked := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok)) + assert.Equal(t, http.StatusForbidden, blocked.Code) + assert.Equal(t, auth.CodeCredentialsChangeRequired, blocked.code()) + assert.Equal(t, auth.CodeCredentialsChangeRequired, + e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "whatever-long-pass"}, bearer(tok)).code()) + + short := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials", + map[string]any{"new_username": "owner", "new_password": "short"}, bearer(tok)) + assert.Equal(t, http.StatusUnprocessableEntity, short.Code) + assert.Equal(t, auth.CodePasswordTooShort, short.code()) + assert.EqualValues(t, 12, short.body["params"].(map[string]any)["min"]) + + same := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials", + map[string]any{"new_username": "owner", "new_password": "initial-secret-pass"}, bearer(tok)) + assert.Equal(t, auth.CodePasswordUnchanged, same.code()) + + badName := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials", + map[string]any{"new_username": "x", "new_password": "a-much-better-secret"}, bearer(tok)) + assert.Equal(t, auth.CodeUsernameInvalid, badName.code()) + + ok := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials", + map[string]any{"new_username": " Owner ", "new_password": "a-much-better-secret"}, bearer(tok)) + require.Equal(t, http.StatusOK, ok.Code, ok.Body.String()) + assert.Equal(t, "owner", user(ok)["username"]) + assert.Equal(t, false, user(ok)["must_change_credentials"]) + assert.NotNil(t, ok.cookie(auth.RefreshCookie)) + + old := e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)) + assert.Equal(t, http.StatusUnauthorized, old.Code) + assert.Equal(t, auth.CodeTokenRevoked, old.code()) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(ok))).Code) + + assert.Equal(t, false, e.do(http.MethodGet, "/api/v1/server-info", nil).body["bootstrap_required"]) + again := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials", + map[string]any{"new_username": "owner", "new_password": "another-better-secret"}, bearer(access(ok))) + assert.Equal(t, http.StatusConflict, again.Code) + + actions := []string{} + for _, a := range e.store.Audit { + actions = append(actions, a.Action) + } + assert.Equal(t, []string{"user.bootstrap_create", "user.bootstrap_credentials"}, actions) + + // После завершения первого запуска удаление всех админов не создаёт нового автоматически. + delete(e.store.Users, admin.ID) + require.NoError(t, e.svc.Bootstrap(ctx)) + assert.Empty(t, e.store.Users) +} + +func TestBootstrapWithConfiguredPassword(t *testing.T) { + e := newEnv(t, func(c *auth.Config) { + c.BootstrapUsername = "Root" + c.BootstrapPassword = "configured-admin-pass" + }) + require.NoError(t, e.svc.Bootstrap(context.Background())) + r := e.mustLogin("root", "configured-admin-pass", "android") + assert.Equal(t, "root", user(r)["username"]) +} + +func TestBootstrapSkippedWhenAdminExists(t *testing.T) { + e := newEnv(t) + e.addUser("boss", "boss-password-123", auth.RoleAdmin) + require.NoError(t, e.svc.Bootstrap(context.Background())) + assert.Len(t, e.store.Users, 1) +} + +func TestLoginErrorsAreIndistinguishable(t *testing.T) { + e := newEnv(t) + e.addUser("alice", "alice-password", auth.RoleUser) + + wrong := e.login("alice", "nope-nope-nope", "web") + unknown := e.login("bob", "nope-nope-nope", "web") + for _, r := range []resp{wrong, unknown} { + assert.Equal(t, http.StatusUnauthorized, r.Code) + assert.Equal(t, auth.CodeInvalidCredentials, r.code()) + } + assert.Equal(t, http.StatusOK, e.login("ALICE", "alice-password", "web").Code, "username is case-insensitive") + + bad := e.do(http.MethodPost, "/api/v1/auth/login", + map[string]any{"username": "alice", "password": "alice-password", "client_type": "ios"}) + assert.Equal(t, http.StatusUnprocessableEntity, bad.Code) +} + +func TestLoginLockout(t *testing.T) { + e := newEnv(t) + u := e.addUser("alice", "alice-password", auth.RoleUser) + + for range 5 { + assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code()) + e.limiter.Reset() + } + locked := e.store.Users[u.ID] + require.NotNil(t, locked.LockedUntil) + assert.Equal(t, e.clock.Now().Add(5*time.Minute), *locked.LockedUntil) + + assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "alice-password", "web").code(), + "correct password is rejected while locked") + assert.Equal(t, 5, e.store.Users[u.ID].FailedLoginCount, "attempts while locked are not counted") + + e.clock.Advance(5 * time.Minute) + assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code()) + assert.Equal(t, e.clock.Now().Add(10*time.Minute), *e.store.Users[u.ID].LockedUntil, "lock doubles") + + e.clock.Advance(10 * time.Minute) + e.limiter.Reset() + e.mustLogin("alice", "alice-password", "web") + assert.Zero(t, e.store.Users[u.ID].FailedLoginCount) + assert.Nil(t, e.store.Users[u.ID].LockedUntil) +} + +func TestLockDurationIsCapped(t *testing.T) { + e := newEnv(t) + u := e.addUser("alice", "alice-password", auth.RoleUser, func(u *auth.User) { u.FailedLoginCount = 40 }) + e.login("alice", "wrong-password", "web") + assert.Equal(t, e.clock.Now().Add(time.Hour), *e.store.Users[u.ID].LockedUntil) +} + +func TestLoginRateLimit(t *testing.T) { + e := newEnv(t) + e.addUser("alice", "alice-password", auth.RoleUser) + for range 5 { + e.login("alice", "wrong-password", "web") + } + r := e.login("alice", "alice-password", "web") + assert.Equal(t, http.StatusTooManyRequests, r.Code) + assert.Equal(t, auth.CodeTooManyAttempts, r.code()) + assert.Equal(t, "60", r.Header().Get("Retry-After")) +} + +func TestBlockedUserCannotLoginOrUseTokens(t *testing.T) { + e := newEnv(t) + u := e.addUser("alice", "alice-password", auth.RoleUser) + tok := access(e.mustLogin("alice", "alice-password", "web")) + + blocked := e.store.Users[u.ID] + blocked.IsBlocked = true + e.store.Users[u.ID] = blocked + + assert.Equal(t, auth.CodeUserBlocked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code()) + r := e.login("alice", "alice-password", "web") + assert.Equal(t, http.StatusForbidden, r.Code) + assert.Equal(t, auth.CodeUserBlocked, r.code()) + assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code(), + "blocked status is not revealed without the password") +} + +func TestWebRefreshRotation(t *testing.T) { + e := newEnv(t) + e.addUser("alice", "alice-password", auth.RoleUser) + r := e.mustLogin("alice", "alice-password", "web") + assert.Nil(t, r.body["refresh_token"], "web never gets refresh token in the body") + c1 := r.cookie(auth.RefreshCookie) + require.NotNil(t, c1) + assert.True(t, c1.HttpOnly) + assert.True(t, c1.Secure) + assert.Equal(t, http.SameSiteStrictMode, c1.SameSite) + assert.Equal(t, "/api/v1/auth", c1.Path) + + e.clock.Advance(time.Minute) + r2 := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1)) + require.Equal(t, http.StatusOK, r2.Code, r2.Body.String()) + c2 := r2.cookie(auth.RefreshCookie) + require.NotNil(t, c2) + assert.NotEqual(t, c1.Value, c2.Value) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(r2))).Code) + + // Вторая вкладка прислала старую cookie сразу после ротации — это гонка, а не кража. + race := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1)) + assert.Equal(t, auth.CodeRefreshRace, race.code()) + assert.Nil(t, race.cookie(auth.RefreshCookie), "race keeps the fresh cookie") + + // Позже старый токен — признак кражи: отзывается вся семья, в том числе новый токен. + e.clock.Advance(time.Minute) + reuse := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1)) + assert.Equal(t, http.StatusUnauthorized, reuse.Code) + assert.Equal(t, auth.CodeRefreshReused, reuse.code()) + cleared := reuse.cookie(auth.RefreshCookie) + require.NotNil(t, cleared) + assert.Equal(t, -1, cleared.MaxAge) + assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c2)).code()) +} + +func TestAndroidRefreshInBody(t *testing.T) { + e := newEnv(t) + e.addUser("alice", "alice-password", auth.RoleUser) + r := e.mustLogin("alice", "alice-password", "android") + assert.Nil(t, r.cookie(auth.RefreshCookie)) + rt, ok := r.body["refresh_token"].(string) + require.True(t, ok) + + r2 := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt}) + require.Equal(t, http.StatusOK, r2.Code, r2.Body.String()) + assert.NotEqual(t, rt, r2.body["refresh_token"]) + + assert.Equal(t, auth.CodeRefreshInvalid, + e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": "garbage"}).code()) + assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", nil).code()) +} + +func TestRefreshExpiry(t *testing.T) { + e := newEnv(t) + e.addUser("alice", "alice-password", auth.RoleUser) + rt := e.mustLogin("alice", "alice-password", "android").body["refresh_token"].(string) + + // Скользящий срок: каждые 29 дней ротация продлевает, но не дальше 180 дней от входа. + for range 6 { + e.clock.Advance(29 * 24 * time.Hour) + r := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt}) + require.Equal(t, http.StatusOK, r.Code, r.Body.String()) + rt = r.body["refresh_token"].(string) + } + e.clock.Advance(7 * 24 * time.Hour) // 181 день от входа + assert.Equal(t, auth.CodeRefreshInvalid, + e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt}).code()) + + rt2 := e.mustLogin("alice", "alice-password", "android").body["refresh_token"].(string) + e.clock.Advance(31 * 24 * time.Hour) + assert.Equal(t, auth.CodeRefreshInvalid, + e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt2}).code()) +} + +func TestAccessTokenErrors(t *testing.T) { + e := newEnv(t) + e.addUser("alice", "alice-password", auth.RoleUser) + tok := access(e.mustLogin("alice", "alice-password", "web")) + + assert.Equal(t, auth.CodeUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil).code()) + assert.Equal(t, auth.CodeTokenInvalid, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok+"x")).code()) + assert.Equal(t, auth.CodeUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil, + func(r *http.Request) { r.Header.Set("Authorization", "Basic abc") }).code()) + + e.clock.Advance(16 * time.Minute) + expired := e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)) + assert.Equal(t, http.StatusUnauthorized, expired.Code) + assert.Equal(t, auth.CodeTokenExpired, expired.code()) +} + +func TestTokenSignedByPreviousKey(t *testing.T) { + clock := &authtest.Clock{T: time.Now()} + oldKey, _ := auth.GenerateKey() + newKey, _ := auth.GenerateKey() + old := auth.NewTokenIssuer(oldKey, nil, time.Minute, clock.Now) + rotated := auth.NewTokenIssuer(newKey, []ed25519.PrivateKey{oldKey}, time.Minute, clock.Now) + other := auth.NewTokenIssuer(newKey, nil, time.Minute, clock.Now) + + tok, err := old.Issue(uuid.New(), auth.RoleUser, uuid.New(), 3) + require.NoError(t, err) + c, err := rotated.Parse(tok) + require.NoError(t, err) + assert.Equal(t, 3, c.TokenVersion) + _, err = other.Parse(tok) + assert.ErrorIs(t, err, auth.ErrTokenInvalid) +} + +func TestChangePassword(t *testing.T) { + e := newEnv(t) + e.addUser("alice", "alice-password", auth.RoleUser) + web := e.mustLogin("alice", "alice-password", "web") + phone := e.mustLogin("alice", "alice-password", "android") + tok := access(web) + + missing := e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "brand-new-pass"}, bearer(tok)) + assert.Equal(t, auth.CodeCurrentPasswordRequired, missing.code()) + wrong := e.do(http.MethodPost, "/api/v1/me/password", + map[string]any{"current_password": "nope", "new_password": "brand-new-pass"}, bearer(tok)) + assert.Equal(t, auth.CodeCurrentPasswordInvalid, wrong.code()) + common := e.do(http.MethodPost, "/api/v1/me/password", + map[string]any{"current_password": "alice-password", "new_password": "Password123"}, bearer(tok)) + assert.Equal(t, auth.CodePasswordTooCommon, common.code()) + + ok := e.do(http.MethodPost, "/api/v1/me/password", + map[string]any{"current_password": "alice-password", "new_password": "brand-new-pass"}, bearer(tok)) + require.Equal(t, http.StatusOK, ok.Code, ok.Body.String()) + assert.NotNil(t, ok.cookie(auth.RefreshCookie), "current web device keeps a session") + + assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code()) + assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", + map[string]any{"refresh_token": phone.body["refresh_token"]}).code(), "other devices are signed out") + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(ok))).Code) + + assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "alice-password", "web").code()) + e.mustLogin("alice", "brand-new-pass", "web") +} + +func TestTemporaryPasswordMustBeChanged(t *testing.T) { + e := newEnv(t) + e.addUser("bob", "temp-pass-123", auth.RoleUser, func(u *auth.User) { u.MustChangePassword = true }) + r := e.mustLogin("bob", "temp-pass-123", "android") + tok := access(r) + assert.Equal(t, true, user(r)["must_change_password"]) + + denied := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok)) + assert.Equal(t, http.StatusForbidden, denied.Code) + assert.Equal(t, auth.CodePasswordChangeRequired, denied.code()) + + ok := e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "bobs-own-pass"}, bearer(tok)) + require.Equal(t, http.StatusOK, ok.Code, ok.Body.String()) + assert.Equal(t, false, user(ok)["must_change_password"]) + assert.NotEmpty(t, ok.body["refresh_token"], "android device gets refresh token in the body") + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(ok))).Code) +} + +func TestPrivilegedPasswordLength(t *testing.T) { + e := newEnv(t) + e.addUser("mod", "moderator-pass-1", auth.RoleModerator) + tok := access(e.mustLogin("mod", "moderator-pass-1", "web")) + r := e.do(http.MethodPost, "/api/v1/me/password", + map[string]any{"current_password": "moderator-pass-1", "new_password": "eleven-char"}, bearer(tok)) + assert.Equal(t, auth.CodePasswordTooShort, r.code()) + assert.EqualValues(t, 12, r.body["params"].(map[string]any)["min"]) + assert.Equal(t, []any{map[string]any{"field": "new_password", "code": auth.CodePasswordTooShort}}, r.body["errors"]) +} + +func TestUpdateMe(t *testing.T) { + e := newEnv(t) + e.addUser("alice", "alice-password", auth.RoleUser) + tok := access(e.mustLogin("alice", "alice-password", "web")) + + r := e.do(http.MethodPatch, "/api/v1/me", map[string]any{ + "display_name": " Alice ", "locale": "en", "ui_prefs": map[string]any{"theme": "light", "crossfade_s": 3}, + }, bearer(tok)) + require.Equal(t, http.StatusOK, r.Code, r.Body.String()) + assert.Equal(t, "Alice", r.body["display_name"]) + assert.Equal(t, "en", r.body["locale"]) + + r = e.do(http.MethodPatch, "/api/v1/me", map[string]any{"ui_prefs": map[string]any{"wave_variant": "liquid"}}, bearer(tok)) + assert.Equal(t, map[string]any{"theme": "light", "crossfade_s": float64(3), "wave_variant": "liquid"}, r.body["ui_prefs"], + "ui_prefs are merged") + assert.Equal(t, "Alice", r.body["display_name"], "absent fields are unchanged") + + r = e.do(http.MethodPatch, "/api/v1/me", map[string]any{"display_name": ""}, bearer(tok)) + assert.Nil(t, r.body["display_name"]) + + assert.Equal(t, auth.CodeLocaleUnsupported, + e.do(http.MethodPatch, "/api/v1/me", map[string]any{"locale": "de"}, bearer(tok)).code()) + assert.Equal(t, auth.CodeUIPrefsInvalid, + e.do(http.MethodPatch, "/api/v1/me", map[string]any{"ui_prefs": map[string]any{"crossfade_s": 9}}, bearer(tok)).code()) + assert.Equal(t, auth.CodeDisplayNameTooLong, + e.do(http.MethodPatch, "/api/v1/me", map[string]any{"display_name": strings.Repeat("я", 65)}, bearer(tok)).code()) +} + +func TestSessions(t *testing.T) { + e := newEnv(t) + e.addUser("alice", "alice-password", auth.RoleUser) + web := e.mustLogin("alice", "alice-password", "web") + e.clock.Advance(time.Second) + phone := e.mustLogin("alice", "alice-password", "android") + tok := access(web) + + list := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok)) + items := list.body["items"].([]any) + require.Len(t, items, 2) + first, second := items[0].(map[string]any), items[1].(map[string]any) + assert.Equal(t, "android", first["client_type"], "newest first") + assert.Equal(t, false, first["current"]) + assert.Equal(t, true, second["current"]) + assert.Equal(t, "Test device", second["device_name"]) + + assert.Equal(t, auth.CodeSessionNotFound, + e.do(http.MethodDelete, "/api/v1/me/sessions/"+uuid.NewString(), nil, bearer(tok)).code()) + del := e.do(http.MethodDelete, "/api/v1/me/sessions/"+first["id"].(string), nil, bearer(tok)) + assert.Equal(t, http.StatusNoContent, del.Code) + assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", + map[string]any{"refresh_token": phone.body["refresh_token"]}).code()) + assert.Len(t, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok)).body["items"], 1) + + // Чужую сессию завершить нельзя. + e.addUser("bob", "bob-password", auth.RoleUser) + bob := access(e.mustLogin("bob", "bob-password", "web")) + assert.Equal(t, auth.CodeSessionNotFound, + e.do(http.MethodDelete, "/api/v1/me/sessions/"+second["id"].(string), nil, bearer(bob)).code()) +} + +func TestLogout(t *testing.T) { + e := newEnv(t) + e.addUser("alice", "alice-password", auth.RoleUser) + web := e.mustLogin("alice", "alice-password", "web") + phone := e.mustLogin("alice", "alice-password", "android") + + out := e.do(http.MethodPost, "/api/v1/auth/logout", nil, bearer(access(web))) + assert.Equal(t, http.StatusNoContent, out.Code) + require.NotNil(t, out.cookie(auth.RefreshCookie)) + assert.Equal(t, -1, out.cookie(auth.RefreshCookie).MaxAge) + assert.Equal(t, auth.CodeRefreshInvalid, + e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(web.cookie(auth.RefreshCookie))).code()) + + rt := phone.body["refresh_token"] + ok := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt}) + require.Equal(t, http.StatusOK, ok.Code, "logout touches only the current session") + + assert.Equal(t, http.StatusNoContent, e.do(http.MethodPost, "/api/v1/auth/logout-all", nil, bearer(access(ok))).Code) + assert.Equal(t, auth.CodeRefreshInvalid, + e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": ok.body["refresh_token"]}).code()) +} + +func TestPublicRoutes(t *testing.T) { + e := newEnv(t) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code) + assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code) +} diff --git a/services/api/internal/auth/authtest/memstore.go b/services/api/internal/auth/authtest/memstore.go new file mode 100644 index 0000000..fb34bbf --- /dev/null +++ b/services/api/internal/auth/authtest/memstore.go @@ -0,0 +1,322 @@ +// Package authtest — тестовые реализации зависимостей auth: хранилище в памяти, часы, лимитер. +package authtest + +import ( + "bytes" + "context" + "encoding/json" + "maps" + "slices" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "pipu/api/internal/auth" +) + +// MemStore — auth.Store в памяти. WithTx откатывает изменения, если fn вернула ошибку. +type MemStore struct { + mu *sync.Mutex + inTx bool + Users map[uuid.UUID]auth.User + Tokens map[uuid.UUID]auth.RefreshToken + Audit []auth.AuditEntry + Settings map[string]any +} + +// NewMemStore создаёт пустое хранилище. +func NewMemStore() *MemStore { + return &MemStore{ + mu: &sync.Mutex{}, + Users: map[uuid.UUID]auth.User{}, + Tokens: map[uuid.UUID]auth.RefreshToken{}, + Settings: map[string]any{}, + } +} + +func (s *MemStore) lock() func() { + if s.inTx { + return func() {} + } + s.mu.Lock() + return s.mu.Unlock +} + +// WithTx реализует auth.Store. +func (s *MemStore) WithTx(_ context.Context, fn func(auth.Store) error) error { + unlock := s.lock() + defer unlock() + users, tokens, audit, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), slices.Clone(s.Audit), maps.Clone(s.Settings) + tx := &MemStore{mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, Settings: s.Settings} + if err := fn(tx); err != nil { + s.Users, s.Tokens, s.Audit, s.Settings = users, tokens, audit, settings + return err + } + s.Users, s.Tokens, s.Audit, s.Settings = tx.Users, tx.Tokens, tx.Audit, tx.Settings + return nil +} + +// CountAdmins реализует auth.Store. +func (s *MemStore) CountAdmins(context.Context) (int, error) { + defer s.lock()() + n := 0 + for _, u := range s.Users { + if u.Role == auth.RoleAdmin && !u.IsBlocked { + n++ + } + } + return n, nil +} + +func (s *MemStore) usernameTaken(username string, except uuid.UUID) bool { + for _, u := range s.Users { + if u.ID != except && strings.EqualFold(u.Username, username) { + return true + } + } + return false +} + +// CreateUser реализует auth.Store. +func (s *MemStore) CreateUser(_ context.Context, u auth.User, _ *uuid.UUID) error { + defer s.lock()() + if s.usernameTaken(u.Username, uuid.Nil) { + return auth.ErrUsernameTaken + } + s.Users[u.ID] = cloneUser(u) + return nil +} + +// cloneUser копирует ui_prefs через JSON, чтобы тест не делил указатели с хранилищем. +func cloneUser(u auth.User) auth.User { + raw, _ := json.Marshal(u.UIPrefs) + u.UIPrefs = auth.UIPrefs{} + _ = json.Unmarshal(raw, &u.UIPrefs) + return u +} + +// GetUserByID реализует auth.Store. +func (s *MemStore) GetUserByID(_ context.Context, id uuid.UUID) (auth.User, error) { + defer s.lock()() + u, ok := s.Users[id] + if !ok { + return auth.User{}, auth.ErrNotFound + } + return cloneUser(u), nil +} + +// GetUserByUsername реализует auth.Store. +func (s *MemStore) GetUserByUsername(_ context.Context, username string) (auth.User, error) { + defer s.lock()() + for _, u := range s.Users { + if strings.EqualFold(u.Username, username) { + return cloneUser(u), nil + } + } + return auth.User{}, auth.ErrNotFound +} + +func (s *MemStore) update(id uuid.UUID, fn func(*auth.User)) error { + defer s.lock()() + u, ok := s.Users[id] + if !ok { + return auth.ErrNotFound + } + fn(&u) + s.Users[id] = u + return nil +} + +// RecordLoginFailure реализует auth.Store. +func (s *MemStore) RecordLoginFailure(_ context.Context, id uuid.UUID, failed int, lockedUntil *time.Time) error { + return s.update(id, func(u *auth.User) { u.FailedLoginCount, u.LockedUntil = failed, lockedUntil }) +} + +// RecordLoginSuccess реализует auth.Store. +func (s *MemStore) RecordLoginSuccess(_ context.Context, id uuid.UUID, at time.Time, newHash *string) error { + return s.update(id, func(u *auth.User) { + u.FailedLoginCount, u.LockedUntil, u.LastLoginAt = 0, nil, &at + if newHash != nil { + u.PasswordHash = *newHash + } + }) +} + +// SetPassword реализует auth.Store. +func (s *MemStore) SetPassword(_ context.Context, id uuid.UUID, hash string) error { + return s.update(id, func(u *auth.User) { + u.PasswordHash, u.MustChangePassword = hash, false + u.TokenVersion++ + }) +} + +// SetCredentials реализует auth.Store. +func (s *MemStore) SetCredentials(_ context.Context, id uuid.UUID, username, hash string) error { + unlock := s.lock() + taken := s.usernameTaken(username, id) + unlock() + if taken { + return auth.ErrUsernameTaken + } + return s.update(id, func(u *auth.User) { + u.Username, u.PasswordHash = username, hash + u.MustChangeCredentials, u.MustChangePassword = false, false + u.TokenVersion++ + }) +} + +// UpdateProfile реализует auth.Store. +func (s *MemStore) UpdateProfile(ctx context.Context, id uuid.UUID, upd auth.ProfileUpdate) (auth.User, error) { + err := s.update(id, func(u *auth.User) { + if upd.DisplayName != nil { + u.DisplayName = *upd.DisplayName + } + if upd.Locale != nil { + u.Locale = *upd.Locale + } + if upd.UIPrefs != nil { + u.UIPrefs = *upd.UIPrefs + } + }) + if err != nil { + return auth.User{}, err + } + return s.GetUserByID(ctx, id) +} + +// InsertRefreshToken реализует auth.Store. +func (s *MemStore) InsertRefreshToken(_ context.Context, t auth.RefreshToken) error { + defer s.lock()() + s.Tokens[t.ID] = t + return nil +} + +// GetRefreshTokenForUpdate реализует auth.Store. +func (s *MemStore) GetRefreshTokenForUpdate(_ context.Context, hash []byte) (auth.RefreshToken, error) { + defer s.lock()() + for _, t := range s.Tokens { + if bytes.Equal(t.TokenHash, hash) { + return t, nil + } + } + return auth.RefreshToken{}, auth.ErrNotFound +} + +// MarkRefreshTokenUsed реализует auth.Store. +func (s *MemStore) MarkRefreshTokenUsed(_ context.Context, id uuid.UUID, at time.Time) error { + defer s.lock()() + t := s.Tokens[id] + t.UsedAt = &at + s.Tokens[id] = t + return nil +} + +func (s *MemStore) revoke(at time.Time, match func(auth.RefreshToken) bool) { + defer s.lock()() + for id, t := range s.Tokens { + if t.RevokedAt == nil && match(t) { + t.RevokedAt = &at + s.Tokens[id] = t + } + } +} + +// RevokeFamily реализует auth.Store. +func (s *MemStore) RevokeFamily(_ context.Context, familyID uuid.UUID, at time.Time) error { + s.revoke(at, func(t auth.RefreshToken) bool { return t.FamilyID == familyID }) + return nil +} + +// RevokeUserSessions реализует auth.Store. +func (s *MemStore) RevokeUserSessions(_ context.Context, userID uuid.UUID, at time.Time) error { + s.revoke(at, func(t auth.RefreshToken) bool { return t.UserID == userID }) + return nil +} + +// ListSessions реализует auth.Store. +func (s *MemStore) ListSessions(_ context.Context, userID uuid.UUID, now time.Time) ([]auth.Session, error) { + defer s.lock()() + var out []auth.Session + for _, t := range s.Tokens { + if t.UserID == userID && t.UsedAt == nil && t.RevokedAt == nil && t.ExpiresAt.After(now) { + out = append(out, auth.Session{ + ID: t.FamilyID, DeviceName: t.DeviceName, ClientType: t.ClientType, + CreatedAt: t.FamilyStartedAt, LastUsedAt: t.CreatedAt, + }) + } + } + slices.SortFunc(out, func(a, b auth.Session) int { return b.LastUsedAt.Compare(a.LastUsedAt) }) + return out, nil +} + +// InsertAudit реализует auth.Store. +func (s *MemStore) InsertAudit(_ context.Context, _ uuid.UUID, e auth.AuditEntry) error { + defer s.lock()() + s.Audit = append(s.Audit, e) + return nil +} + +// GetSettingBool реализует auth.Store. +func (s *MemStore) GetSettingBool(_ context.Context, key string) (bool, error) { + defer s.lock()() + v, _ := s.Settings[key].(bool) + return v, nil +} + +// SetSetting реализует auth.Store. +func (s *MemStore) SetSetting(_ context.Context, key string, value any) error { + defer s.lock()() + s.Settings[key] = value + return nil +} + +var _ auth.Store = (*MemStore)(nil) + +// Clock — управляемые часы. +type Clock struct { + mu sync.Mutex + T time.Time +} + +// Now реализует auth.Clock. +func (c *Clock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.T +} + +// Advance сдвигает часы вперёд. +func (c *Clock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.T = c.T.Add(d) +} + +// Limiter — счётчик попыток в памяти без учёта окна (окно сбрасывает Reset). +type Limiter struct { + mu sync.Mutex + counts map[string]int +} + +// Allow реализует auth.Limiter. +func (l *Limiter) Allow(_ context.Context, key string, limit int, window time.Duration) (bool, int, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.counts == nil { + l.counts = map[string]int{} + } + l.counts[key]++ + if l.counts[key] > limit { + return false, int(window / time.Second), nil + } + return true, 0, nil +} + +// Reset обнуляет счётчики (как истечение окна). +func (l *Limiter) Reset() { + l.mu.Lock() + defer l.mu.Unlock() + l.counts = nil +} diff --git a/services/api/internal/auth/common_passwords.txt b/services/api/internal/auth/common_passwords.txt new file mode 100644 index 0000000..5cfa520 --- /dev/null +++ b/services/api/internal/auth/common_passwords.txt @@ -0,0 +1,152 @@ +# Частые пароли длиной от 8 символов (короче отсекает правило длины). Сравнение без учёта регистра. +# Неполный список: расширяется добавлением строк, по одной на строку. +12345678 +123456789 +1234567890 +12345678910 +123123123 +1234512345 +11111111 +111111111 +1111111111 +00000000 +0000000000 +22222222 +55555555 +66666666 +77777777 +88888888 +99999999 +87654321 +987654321 +0987654321 +11223344 +12341234 +12121212 +13131313 +147258369 +123456789a +123456789q +1q2w3e4r +1q2w3e4r5t +1q2w3e4r5t6y +1qaz2wsx +1qaz2wsx3edc +zaq12wsx +qwertyui +qwertyuiop +qwerty123 +qwerty1234 +qwerty12345 +qwerty123456 +qwertyqwerty +asdfghjkl +asdfasdf +zxcvbnm123 +zxcvbnmm +password +password1 +password12 +password123 +password1234 +password! +passw0rd +p@ssw0rd +p@ssword +pa55word +iloveyou +iloveyou1 +iloveyou2 +sunshine +princess +football +baseball +basketball +superman +batman123 +starwars +whatever +trustno1 +letmein1 +welcome1 +welcome123 +changeme +changeme123 +administrator +admin123 +admin1234 +admin12345 +adminadmin +administrator1 +rootroot +root1234 +master123 +dragon123 +monkey123 +shadow123 +michael1 +jennifer +computer +internet +abcd1234 +abc12345 +abc123456 +abcdefgh +aaaaaaaa +aa123456 +a1234567 +a12345678 +q1w2e3r4 +q1w2e3r4t5 +asdf1234 +1234qwer +qwer1234 +q1234567 +myspace1 +charlie1 +freedom1 +mustang1 +liverpool +chelsea1 +arsenal1 +fuckyou1 +secret123 +samsung1 +pokemon1 +minecraft +loveyou1 +hello123 +helloworld +yankees1 +jordan23 +access14 +cheese12 +michelle +nicole12 +jessica1 +ashley12 +killer12 +soccer12 +hunter12 +ranger12 +buster12 +thomas12 +tigger12 +summer12 +spring12 +winter12 +autumn12 +qazwsxedc +1234abcd +pipu1234 +pipumusic +music123 +music1234 +musiclover +parol123 +parol1234 +privet123 +qwertyuiop123 +йцукенгш +пароль123 +пароль1234 diff --git a/services/api/internal/auth/handler.go b/services/api/internal/auth/handler.go new file mode 100644 index 0000000..500264e --- /dev/null +++ b/services/api/internal/auth/handler.go @@ -0,0 +1,264 @@ +package auth + +import ( + "context" + "errors" + "net/http" + "time" + + "pipu/api/internal/api/gen" + "pipu/api/internal/platform/apperr" +) + +// RefreshCookie — имя cookie с refresh-токеном веб-клиента. +const RefreshCookie = "pipu_rt" + +const refreshCookiePath = "/api/v1/auth" + +// Handler реализует auth- и me-части gen.StrictServerInterface. +type Handler struct { + svc *Service + cookieSecure bool +} + +// NewHandler создаёт обработчик. cookieSecure = false допустим только для разработки по http. +func NewHandler(svc *Service, cookieSecure bool) *Handler { + return &Handler{svc: svc, cookieSecure: cookieSecure} +} + +func principal(ctx context.Context) (Principal, error) { + p, ok := PrincipalFrom(ctx) + if !ok { + return Principal{}, apperr.Unauthorized(CodeUnauthorized, "not authenticated") + } + return p, nil +} + +// Login реализует gen.StrictServerInterface. +func (h *Handler) Login(ctx context.Context, req gen.LoginRequestObject) (gen.LoginResponseObject, error) { + in := LoginInput{ + Username: req.Body.Username, + Password: req.Body.Password, + ClientType: ClientType(req.Body.ClientType), + IP: clientIP(ctx), + } + if req.Body.DeviceName != nil { + in.DeviceName = *req.Body.DeviceName + } + t, err := h.svc.Login(ctx, in) + if err != nil { + return nil, err + } + return gen.Login200JSONResponse(h.tokensDTO(ctx, t)), nil +} + +// RefreshTokens реализует gen.StrictServerInterface. +func (h *Handler) RefreshTokens(ctx context.Context, req gen.RefreshTokensRequestObject) (gen.RefreshTokensResponseObject, error) { + var token string + if req.Body != nil && req.Body.RefreshToken != nil { + token = *req.Body.RefreshToken + } else if req.Params.PipuRt != nil { + token = *req.Params.PipuRt + } + t, err := h.svc.Refresh(ctx, token) + if err != nil { + var ae *apperr.Error + // Токен больше не годится — убираем cookie, чтобы браузер не присылал его снова. + // При гонке двух вкладок cookie уже новая, её не трогаем. + if req.Params.PipuRt != nil && errors.As(err, &ae) && ae.Code != CodeRefreshRace { + h.clearCookie(ctx) + } + return nil, err + } + return gen.RefreshTokens200JSONResponse(h.tokensDTO(ctx, t)), nil +} + +// Logout реализует gen.StrictServerInterface. +func (h *Handler) Logout(ctx context.Context, _ gen.LogoutRequestObject) (gen.LogoutResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + if err := h.svc.Logout(ctx, p); err != nil { + return nil, err + } + h.clearCookie(ctx) + return gen.Logout204Response{}, nil +} + +// LogoutAll реализует gen.StrictServerInterface. +func (h *Handler) LogoutAll(ctx context.Context, _ gen.LogoutAllRequestObject) (gen.LogoutAllResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + if err := h.svc.LogoutAll(ctx, p); err != nil { + return nil, err + } + h.clearCookie(ctx) + return gen.LogoutAll204Response{}, nil +} + +// GetMe реализует gen.StrictServerInterface. +func (h *Handler) GetMe(ctx context.Context, _ gen.GetMeRequestObject) (gen.GetMeResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + u, err := h.svc.Me(ctx, p) + if err != nil { + return nil, err + } + return gen.GetMe200JSONResponse(MeDTO(u)), nil +} + +// UpdateMe реализует gen.StrictServerInterface. +func (h *Handler) UpdateMe(ctx context.Context, req gen.UpdateMeRequestObject) (gen.UpdateMeResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + patch := MePatch{DisplayName: req.Body.DisplayName, Locale: req.Body.Locale} + if req.Body.UiPrefs != nil { + prefs := uiPrefsFromDTO(*req.Body.UiPrefs) + patch.UIPrefs = &prefs + } + u, err := h.svc.UpdateMe(ctx, p, patch) + if err != nil { + return nil, err + } + return gen.UpdateMe200JSONResponse(MeDTO(u)), nil +} + +// ChangePassword реализует gen.StrictServerInterface. +func (h *Handler) ChangePassword(ctx context.Context, req gen.ChangePasswordRequestObject) (gen.ChangePasswordResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + t, err := h.svc.ChangePassword(ctx, p, req.Body.CurrentPassword, req.Body.NewPassword) + if err != nil { + return nil, err + } + return gen.ChangePassword200JSONResponse(h.tokensDTO(ctx, t)), nil +} + +// SetBootstrapCredentials реализует gen.StrictServerInterface. +func (h *Handler) SetBootstrapCredentials(ctx context.Context, req gen.SetBootstrapCredentialsRequestObject) (gen.SetBootstrapCredentialsResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + t, err := h.svc.SetBootstrapCredentials(ctx, p, req.Body.NewUsername, req.Body.NewPassword) + if err != nil { + return nil, err + } + return gen.SetBootstrapCredentials200JSONResponse(h.tokensDTO(ctx, t)), nil +} + +// ListMySessions реализует gen.StrictServerInterface. +func (h *Handler) ListMySessions(ctx context.Context, _ gen.ListMySessionsRequestObject) (gen.ListMySessionsResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + sessions, err := h.svc.ListSessions(ctx, p) + if err != nil { + return nil, err + } + items := make([]gen.Session, 0, len(sessions)) + for _, s := range sessions { + items = append(items, gen.Session{ + Id: s.ID, + DeviceName: s.DeviceName, + ClientType: gen.ClientType(s.ClientType), + CreatedAt: s.CreatedAt, + LastUsedAt: s.LastUsedAt, + Current: s.ID == p.SessionID, + }) + } + return gen.ListMySessions200JSONResponse{Items: items}, nil +} + +// RevokeMySession реализует gen.StrictServerInterface. +func (h *Handler) RevokeMySession(ctx context.Context, req gen.RevokeMySessionRequestObject) (gen.RevokeMySessionResponseObject, error) { + p, err := principal(ctx) + if err != nil { + return nil, err + } + if err := h.svc.RevokeSession(ctx, p, req.Id); err != nil { + return nil, err + } + return gen.RevokeMySession204Response{}, nil +} + +// tokensDTO собирает ответ с токенами: веб получает refresh в cookie, Android — в теле. +func (h *Handler) tokensDTO(ctx context.Context, t Tokens) gen.AuthTokens { + out := gen.AuthTokens{ + AccessToken: t.Access, + TokenType: gen.Bearer, + ExpiresIn: int(t.ExpiresIn / time.Second), + User: MeDTO(t.User), + } + if t.ClientType == ClientWeb { + setCookie(ctx, &http.Cookie{ + Name: RefreshCookie, + Value: t.Refresh, + Path: refreshCookiePath, + MaxAge: int(h.svc.cfg.RefreshTTL / time.Second), + HttpOnly: true, + Secure: h.cookieSecure, + SameSite: http.SameSiteStrictMode, + }) + } else { + refresh := t.Refresh + out.RefreshToken = &refresh + } + return out +} + +func (h *Handler) clearCookie(ctx context.Context) { + setCookie(ctx, &http.Cookie{ + Name: RefreshCookie, Path: refreshCookiePath, MaxAge: -1, + HttpOnly: true, Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode, + }) +} + +// MeDTO переводит пользователя в схему Me. +func MeDTO(u User) gen.Me { + return gen.Me{ + Id: u.ID, + Username: u.Username, + DisplayName: u.DisplayName, + Role: gen.Role(u.Role), + Locale: u.Locale, + UiPrefs: uiPrefsDTO(u.UIPrefs), + MustChangePassword: u.MustChangePassword, + MustChangeCredentials: u.MustChangeCredentials, + CreatedAt: u.CreatedAt, + } +} + +func uiPrefsDTO(p UIPrefs) gen.UiPrefs { + return gen.UiPrefs{ + Theme: (*gen.UiPrefsTheme)(p.Theme), + WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant), + WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer), + MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer), + Ambient: p.Ambient, + CrossfadeS: p.CrossfadeS, + Normalize: p.Normalize, + } +} + +func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs { + return UIPrefs{ + Theme: (*string)(p.Theme), + WaveVariant: (*string)(p.WaveVariant), + WebPlayer: (*string)(p.WebPlayer), + MobilePlayer: (*string)(p.MobilePlayer), + Ambient: p.Ambient, + CrossfadeS: p.CrossfadeS, + Normalize: p.Normalize, + } +} diff --git a/services/api/internal/auth/limiter.go b/services/api/internal/auth/limiter.go new file mode 100644 index 0000000..f183420 --- /dev/null +++ b/services/api/internal/auth/limiter.go @@ -0,0 +1,41 @@ +package auth + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +// RedisLimiter — счётчик попыток с фиксированным окном в Redis. +type RedisLimiter struct{ rdb redis.Cmdable } + +// NewRedisLimiter создаёт лимитер. +func NewRedisLimiter(rdb redis.Cmdable) *RedisLimiter { return &RedisLimiter{rdb: rdb} } + +// Первый INCR ставит срок окна; ответ — {счётчик, оставшийся TTL в мс}. +var incrWindow = redis.NewScript(` +local n = redis.call('INCR', KEYS[1]) +if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end +return {n, redis.call('PTTL', KEYS[1])} +`) + +// Allow реализует Limiter. +func (l *RedisLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) { + res, err := incrWindow.Run(ctx, l.rdb, []string{"rl:" + key}, window.Milliseconds()).Int64Slice() + if err != nil { + return false, 0, fmt.Errorf("auth: rate limit: %w", err) + } + if len(res) != 2 { + return false, 0, fmt.Errorf("auth: rate limit: unexpected reply %v", res) + } + if res[0] <= int64(limit) { + return true, 0, nil + } + retry := int((res[1] + 999) / 1000) + if retry < 1 { + retry = 1 + } + return false, retry, nil +} diff --git a/services/api/internal/auth/middleware.go b/services/api/internal/auth/middleware.go new file mode 100644 index 0000000..657fd60 --- /dev/null +++ b/services/api/internal/auth/middleware.go @@ -0,0 +1,162 @@ +package auth + +import ( + "context" + "io" + "log/slog" + "net" + "net/http" + "strings" + + "pipu/api/internal/platform/apperr" + "pipu/api/internal/platform/httpx" +) + +type ctxKey int + +const ( + principalKey ctxKey = iota + requestKey +) + +// requestInfo — то, что strict-обработчикам нужно от HTTP-запроса, но не входит в контракт: +// адрес клиента и cookie, которые нужно выставить в ответе. +type requestInfo struct { + ip string + cookies []*http.Cookie +} + +// PrincipalFrom возвращает пользователя запроса (ok = false для публичных эндпоинтов). +func PrincipalFrom(ctx context.Context) (Principal, bool) { + p, ok := ctx.Value(principalKey).(Principal) + return p, ok +} + +// WithPrincipal кладёт пользователя в контекст (для тестов других модулей). +func WithPrincipal(ctx context.Context, p Principal) context.Context { + return context.WithValue(ctx, principalKey, p) +} + +func clientIP(ctx context.Context) string { + if ri, ok := ctx.Value(requestKey).(*requestInfo); ok { + return ri.ip + } + return "" +} + +func setCookie(ctx context.Context, c *http.Cookie) { + if ri, ok := ctx.Value(requestKey).(*requestInfo); ok { + ri.cookies = append(ri.cookies, c) + } +} + +// Публичные эндпоинты /api/v1 — без access-токена. +var publicRoutes = map[string]bool{ + "GET /api/v1/server-info": true, + "POST /api/v1/auth/login": true, + "POST /api/v1/auth/refresh": true, +} + +// Что разрешено, пока пользователь обязан сменить учётные данные / пароль. +var ( + credentialsChangeRoutes = map[string]bool{ + "GET /api/v1/me": true, + "POST /api/v1/me/bootstrap-credentials": true, + "POST /api/v1/auth/logout": true, + } + passwordChangeRoutes = map[string]bool{ + "GET /api/v1/me": true, + "POST /api/v1/me/password": true, + "POST /api/v1/auth/logout": true, + } +) + +// Middleware аутентифицирует запросы к /api/v1 по Bearer-токену и ограничивает пользователей, +// обязанных сменить пароль или учётные данные (docs/auth-and-roles.md). +func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ri := &requestInfo{ip: hostOnly(r.RemoteAddr)} + ctx := context.WithValue(r.Context(), requestKey, ri) + w = &cookieWriter{ResponseWriter: w, ri: ri} + + route := r.Method + " " + strings.TrimSuffix(r.URL.Path, "/") + if route == "POST /api/v1/auth/refresh" && r.ContentLength == 0 { + // Веб шлёт refresh без тела (токен в cookie), а strict-сервер oapi-codegen + // декодирует необязательное тело всё равно и отвечает 400 на пустое. + r.Body = io.NopCloser(strings.NewReader("{}")) + r.ContentLength = 2 + } + if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] { + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + token, ok := bearer(r.Header.Get("Authorization")) + if !ok { + httpx.WriteError(log, w, r, apperr.Unauthorized(CodeUnauthorized, "bearer token required")) + return + } + p, err := svc.Authenticate(ctx, token) + if err != nil { + httpx.WriteError(log, w, r, err) + return + } + switch { + case p.MustChangeCredentials && !credentialsChangeRoutes[route]: + httpx.WriteError(log, w, r, apperr.Forbidden(CodeCredentialsChangeRequired, "change bootstrap credentials first")) + return + case p.MustChangePassword && !passwordChangeRoutes[route]: + httpx.WriteError(log, w, r, apperr.Forbidden(CodePasswordChangeRequired, "change the temporary password first")) + return + } + next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p))) + }) + } +} + +func bearer(h string) (string, bool) { + const prefix = "bearer " + if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) { + return "", false + } + t := strings.TrimSpace(h[len(prefix):]) + return t, t != "" +} + +func hostOnly(addr string) string { + if host, _, err := net.SplitHostPort(addr); err == nil { + return host + } + return addr +} + +// cookieWriter добавляет накопленные обработчиком cookie перед записью заголовков ответа. +type cookieWriter struct { + http.ResponseWriter + ri *requestInfo + wrote bool +} + +func (w *cookieWriter) flush() { + if w.wrote { + return + } + w.wrote = true + for _, c := range w.ri.cookies { + http.SetCookie(w.ResponseWriter, c) + } +} + +func (w *cookieWriter) WriteHeader(code int) { + w.flush() + w.ResponseWriter.WriteHeader(code) +} + +func (w *cookieWriter) Write(b []byte) (int, error) { + w.flush() + return w.ResponseWriter.Write(b) +} + +// Unwrap нужен http.ResponseController (flush, дедлайны) для обёрнутого writer. +func (w *cookieWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } diff --git a/services/api/internal/auth/model.go b/services/api/internal/auth/model.go new file mode 100644 index 0000000..0604e32 --- /dev/null +++ b/services/api/internal/auth/model.go @@ -0,0 +1,105 @@ +// Package auth — пользователи, вход, токены, политика прав (docs/auth-and-roles.md). +package auth + +import ( + "time" + + "github.com/google/uuid" +) + +// Role — роль пользователя. +type Role string + +// Роли по возрастанию прав. +const ( + RoleUser Role = "user" + RoleModerator Role = "moderator" + RoleAdmin Role = "admin" +) + +// Valid сообщает, известна ли роль. +func (r Role) Valid() bool { return r == RoleUser || r == RoleModerator || r == RoleAdmin } + +// ClientType — тип клиента, от него зависит доставка refresh-токена. +type ClientType string + +// Типы клиентов. +const ( + ClientWeb ClientType = "web" + ClientAndroid ClientType = "android" +) + +// Valid сообщает, известен ли тип клиента. +func (c ClientType) Valid() bool { return c == ClientWeb || c == ClientAndroid } + +// UIPrefs — настройки интерфейса пользователя (users.ui_prefs). Пустые поля — значения по умолчанию клиента. +type UIPrefs struct { + Theme *string `json:"theme,omitempty"` + WaveVariant *string `json:"wave_variant,omitempty"` + WebPlayer *string `json:"web_player,omitempty"` + MobilePlayer *string `json:"mobile_player,omitempty"` + Ambient *bool `json:"ambient,omitempty"` + CrossfadeS *int `json:"crossfade_s,omitempty"` + Normalize *bool `json:"normalize,omitempty"` +} + +// User — учётная запись. +type User struct { + ID uuid.UUID + Username string + DisplayName *string + PasswordHash string + Role Role + Locale string + UIPrefs UIPrefs + MustChangePassword bool + MustChangeCredentials bool + IsBlocked bool + TokenVersion int + FailedLoginCount int + LockedUntil *time.Time + CreatedAt time.Time + LastLoginAt *time.Time +} + +// RefreshToken — строка refresh_tokens. Сам токен в БД не хранится, только SHA-256. +type RefreshToken struct { + ID uuid.UUID + UserID uuid.UUID + FamilyID uuid.UUID + FamilyStartedAt time.Time + TokenHash []byte + DeviceName *string + ClientType ClientType + CreatedAt time.Time + ExpiresAt time.Time + UsedAt *time.Time + RevokedAt *time.Time +} + +// Session — активная семья refresh-токенов (одно устройство). +type Session struct { + ID uuid.UUID // family_id + DeviceName *string + ClientType ClientType + CreatedAt time.Time // начало семьи (вход) + LastUsedAt time.Time // последняя ротация +} + +// Principal — аутентифицированный пользователь запроса (из access-токена и актуальной строки users). +type Principal struct { + UserID uuid.UUID + Role Role + SessionID uuid.UUID + MustChangePassword bool + MustChangeCredentials bool +} + +// AuditEntry — запись audit_log. +type AuditEntry struct { + ActorID *uuid.UUID + Action string + TargetType string + TargetID *uuid.UUID + Payload map[string]any +} diff --git a/services/api/internal/auth/password.go b/services/api/internal/auth/password.go new file mode 100644 index 0000000..738d2ce --- /dev/null +++ b/services/api/internal/auth/password.go @@ -0,0 +1,111 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2Params — параметры argon2id. Хранятся в самом хэше (формат PHC), поэтому их можно менять: +// старые хэши проверяются со своими параметрами и пересчитываются при следующем входе. +type Argon2Params struct { + MemoryKiB uint32 + Time uint32 + Threads uint8 + SaltLen int + KeyLen uint32 +} + +// DefaultArgon2Params — m=64 MiB, t=3, p=2 (docs/auth-and-roles.md). +var DefaultArgon2Params = Argon2Params{MemoryKiB: 64 * 1024, Time: 3, Threads: 2, SaltLen: 16, KeyLen: 32} + +// Hasher хэширует и проверяет пароли. +type Hasher struct{ p Argon2Params } + +// NewHasher создаёт Hasher с заданными параметрами. +func NewHasher(p Argon2Params) *Hasher { return &Hasher{p: p} } + +// Hash возвращает PHC-строку `$argon2id$v=19$m=…,t=…,p=…$salt$key`. +func (h *Hasher) Hash(password string) (string, error) { + salt := make([]byte, h.p.SaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("auth: salt: %w", err) + } + key := argon2.IDKey([]byte(password), salt, h.p.Time, h.p.MemoryKiB, h.p.Threads, h.p.KeyLen) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, h.p.MemoryKiB, h.p.Time, h.p.Threads, + b64.EncodeToString(salt), b64.EncodeToString(key)), nil +} + +// Verify проверяет пароль. needsRehash = true, если хэш посчитан с устаревшими параметрами. +func (h *Hasher) Verify(password, encoded string) (ok, needsRehash bool, err error) { + p, salt, key, err := decodePHC(encoded) + if err != nil { + return false, false, err + } + got := argon2.IDKey([]byte(password), salt, p.Time, p.MemoryKiB, p.Threads, uint32(len(key))) + if subtle.ConstantTimeCompare(got, key) != 1 { + return false, false, nil + } + needsRehash = p.MemoryKiB != h.p.MemoryKiB || p.Time != h.p.Time || p.Threads != h.p.Threads || + len(salt) != h.p.SaltLen || uint32(len(key)) != h.p.KeyLen + return true, needsRehash, nil +} + +var b64 = base64.RawStdEncoding + +var errBadHash = errors.New("auth: malformed password hash") + +func decodePHC(s string) (Argon2Params, []byte, []byte, error) { + var p Argon2Params + parts := strings.Split(s, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return p, nil, nil, errBadHash + } + var v int + if _, err := fmt.Sscanf(parts[2], "v=%d", &v); err != nil || v != argon2.Version { + return p, nil, nil, errBadHash + } + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.MemoryKiB, &p.Time, &p.Threads); err != nil { + return p, nil, nil, errBadHash + } + salt, err := b64.DecodeString(parts[4]) + if err != nil { + return p, nil, nil, errBadHash + } + key, err := b64.DecodeString(parts[5]) + if err != nil || len(key) == 0 { + return p, nil, nil, errBadHash + } + p.SaltLen, p.KeyLen = len(salt), uint32(len(key)) + return p, salt, key, nil +} + +// Алфавит временных паролей: без похожих символов (0/O, 1/l/I), чтобы пароль легко продиктовать. +const tempPasswordAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +// GeneratePassword возвращает случайный пароль длины n из tempPasswordAlphabet. +func GeneratePassword(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("auth: random password: %w", err) + } + // 256 % 56 != 0: отбрасываем байты из «хвоста», чтобы распределение было равномерным. + const limit = 256 - 256%len(tempPasswordAlphabet) + out := make([]byte, 0, n) + for len(out) < n { + for _, b := range buf { + if int(b) < limit && len(out) < n { + out = append(out, tempPasswordAlphabet[int(b)%len(tempPasswordAlphabet)]) + } + } + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("auth: random password: %w", err) + } + } + return string(out), nil +} diff --git a/services/api/internal/auth/password_test.go b/services/api/internal/auth/password_test.go new file mode 100644 index 0000000..6e5af16 --- /dev/null +++ b/services/api/internal/auth/password_test.go @@ -0,0 +1,121 @@ +package auth + +import ( + "encoding/base64" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "pipu/api/internal/platform/apperr" +) + +var fastArgon = Argon2Params{MemoryKiB: 64, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32} + +func TestHasher(t *testing.T) { + h := NewHasher(fastArgon) + enc, err := h.Hash("correct horse") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(enc, "$argon2id$v=19$m=64,t=1,p=1$"), enc) + + ok, rehash, err := h.Verify("correct horse", enc) + require.NoError(t, err) + assert.True(t, ok) + assert.False(t, rehash) + + ok, _, err = h.Verify("wrong horse", enc) + require.NoError(t, err) + assert.False(t, ok) + + other, _ := h.Hash("correct horse") + assert.NotEqual(t, enc, other, "salt is random") + + stronger := NewHasher(Argon2Params{MemoryKiB: 128, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}) + ok, rehash, err = stronger.Verify("correct horse", enc) + require.NoError(t, err) + assert.True(t, ok, "old params are read from the hash") + assert.True(t, rehash) +} + +func TestHasherRejectsMalformed(t *testing.T) { + h := NewHasher(fastArgon) + for _, s := range []string{"", "plain", "$argon2i$v=19$m=64,t=1,p=1$c2FsdA$a2V5", "$argon2id$v=18$m=64,t=1,p=1$c2FsdA$a2V5", + "$argon2id$v=19$m=x,t=1,p=1$c2FsdA$a2V5", "$argon2id$v=19$m=64,t=1,p=1$!!$a2V5", "$argon2id$v=19$m=64,t=1,p=1$c2FsdA$"} { + _, _, err := h.Verify("x", s) + assert.Error(t, err, s) + } +} + +func TestGeneratePassword(t *testing.T) { + seen := map[string]bool{} + for range 50 { + p, err := GeneratePassword(12) + require.NoError(t, err) + assert.Len(t, p, 12) + for _, r := range p { + assert.Contains(t, tempPasswordAlphabet, string(r)) + } + seen[p] = true + } + assert.Len(t, seen, 50) +} + +func TestValidatePassword(t *testing.T) { + cases := []struct { + name, password, username string + role Role + code string + }{ + {"user ok", "eightchr", "alice", RoleUser, ""}, + {"user short", "sevench", "alice", RoleUser, CodePasswordTooShort}, + {"moderator needs 12", "elevenchars", "mod", RoleModerator, CodePasswordTooShort}, + {"admin ok", "twelve-chars", "root", RoleAdmin, ""}, + {"runes not bytes", "пароль-ок", "alice", RoleUser, ""}, + {"same as username", "Alexander", "alexander", RoleUser, CodePasswordSameAsUsername}, + {"common", "Password123", "alice", RoleUser, CodePasswordTooCommon}, + {"common cyrillic", "пароль123", "alice", RoleUser, CodePasswordTooCommon}, + {"too long", strings.Repeat("a", MaxPassword+1), "alice", RoleUser, CodePasswordTooLong}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidatePassword("new_password", tc.password, tc.username, tc.role) + if tc.code == "" { + assert.NoError(t, err) + return + } + var ae *apperr.Error + require.ErrorAs(t, err, &ae) + assert.Equal(t, tc.code, ae.Code) + assert.Equal(t, "new_password", ae.Fields[0].Field) + }) + } +} + +func TestValidateUsername(t *testing.T) { + for _, ok := range []string{"abc", "john.doe", "a_b-c", strings.Repeat("a", 32)} { + assert.NoError(t, ValidateUsername("username", ok), ok) + } + for _, bad := range []string{"ab", "John", "имя", "a b", "a@b", strings.Repeat("a", 33)} { + assert.Error(t, ValidateUsername("username", bad), bad) + } + assert.Equal(t, "john", NormalizeUsername(" John ")) +} + +func TestParsePrivateKey(t *testing.T) { + seed := "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + k1, err := ParsePrivateKey(seed) + require.NoError(t, err) + k2, err := ParsePrivateKey(strings.TrimRight(strings.NewReplacer("+", "-", "/", "_").Replace(seed), "=")) + require.NoError(t, err) + assert.Equal(t, k1, k2) + k3, err := ParsePrivateKey(b64std(k1)) + require.NoError(t, err) + assert.Equal(t, k1, k3, "64-byte key form") + _, err = ParsePrivateKey("c2hvcnQ=") + assert.Error(t, err) + _, err = ParsePrivateKey("not base64 !!") + assert.Error(t, err) +} + +func b64std(b []byte) string { return base64.StdEncoding.EncodeToString(b) } diff --git a/services/api/internal/auth/pgstore.go b/services/api/internal/auth/pgstore.go new file mode 100644 index 0000000..4656611 --- /dev/null +++ b/services/api/internal/auth/pgstore.go @@ -0,0 +1,322 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// querier — общее у пула и транзакции. +type querier interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// PgStore — Store поверх PostgreSQL. +type PgStore struct { + pool *pgxpool.Pool + q querier +} + +// NewPgStore создаёт Store на пуле соединений. +func NewPgStore(pool *pgxpool.Pool) *PgStore { return &PgStore{pool: pool, q: pool} } + +// WithTx реализует Store. Вложенный вызов выполняется в уже открытой транзакции. +func (s *PgStore) WithTx(ctx context.Context, fn func(Store) error) error { + if s.pool == nil { + return fn(s) + } + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { return fn(&PgStore{q: tx}) }) +} + +const userColumns = `id, username, display_name, password_hash, role, locale, ui_prefs, must_change_password, + must_change_credentials, is_blocked, token_version, failed_login_count, locked_until, created_at, last_login_at` + +func scanUser(row pgx.Row) (User, error) { + var ( + u User + role string + prefs []byte + ) + err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &role, &u.Locale, &prefs, + &u.MustChangePassword, &u.MustChangeCredentials, &u.IsBlocked, &u.TokenVersion, &u.FailedLoginCount, + &u.LockedUntil, &u.CreatedAt, &u.LastLoginAt) + if errors.Is(err, pgx.ErrNoRows) { + return User{}, ErrNotFound + } + if err != nil { + return User{}, fmt.Errorf("auth: scan user: %w", err) + } + u.Role = Role(role) + if err := json.Unmarshal(prefs, &u.UIPrefs); err != nil { + return User{}, fmt.Errorf("auth: decode ui_prefs: %w", err) + } + return u, nil +} + +// CountAdmins реализует Store. Заблокированные админы не считаются: войти они не могут. +func (s *PgStore) CountAdmins(ctx context.Context) (int, error) { + var n int + err := s.q.QueryRow(ctx, `SELECT count(*) FROM users WHERE role = 'admin' AND NOT is_blocked`).Scan(&n) + if err != nil { + return 0, fmt.Errorf("auth: count admins: %w", err) + } + return n, nil +} + +// CreateUser реализует Store. +func (s *PgStore) CreateUser(ctx context.Context, u User, createdBy *uuid.UUID) error { + prefs, err := json.Marshal(u.UIPrefs) + if err != nil { + return err + } + _, err = s.q.Exec(ctx, ` + INSERT INTO users (id, username, display_name, password_hash, role, locale, ui_prefs, + must_change_password, must_change_credentials, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)`, + u.ID, u.Username, u.DisplayName, u.PasswordHash, string(u.Role), u.Locale, prefs, + u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt) + if isUniqueViolation(err) { + return ErrUsernameTaken + } + if err != nil { + return fmt.Errorf("auth: create user: %w", err) + } + return nil +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +// GetUserByID реализует Store. +func (s *PgStore) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) { + return scanUser(s.q.QueryRow(ctx, `SELECT `+userColumns+` FROM users WHERE id = $1`, id)) +} + +// GetUserByUsername реализует Store (username — citext, регистр не важен). +func (s *PgStore) GetUserByUsername(ctx context.Context, username string) (User, error) { + return scanUser(s.q.QueryRow(ctx, `SELECT `+userColumns+` FROM users WHERE username = $1`, username)) +} + +// RecordLoginFailure реализует Store. +func (s *PgStore) RecordLoginFailure(ctx context.Context, id uuid.UUID, failedCount int, lockedUntil *time.Time) error { + _, err := s.q.Exec(ctx, `UPDATE users SET failed_login_count = $2, locked_until = $3 WHERE id = $1`, + id, failedCount, lockedUntil) + if err != nil { + return fmt.Errorf("auth: record login failure: %w", err) + } + return nil +} + +// RecordLoginSuccess реализует Store. +func (s *PgStore) RecordLoginSuccess(ctx context.Context, id uuid.UUID, at time.Time, newHash *string) error { + _, err := s.q.Exec(ctx, ` + UPDATE users SET failed_login_count = 0, locked_until = NULL, last_login_at = $2, + password_hash = COALESCE($3, password_hash) + WHERE id = $1`, id, at, newHash) + if err != nil { + return fmt.Errorf("auth: record login success: %w", err) + } + return nil +} + +// SetPassword реализует Store. +func (s *PgStore) SetPassword(ctx context.Context, id uuid.UUID, hash string) error { + _, err := s.q.Exec(ctx, ` + UPDATE users SET password_hash = $2, must_change_password = false, + token_version = token_version + 1, updated_at = now() + WHERE id = $1`, id, hash) + if err != nil { + return fmt.Errorf("auth: set password: %w", err) + } + return nil +} + +// SetCredentials реализует Store. +func (s *PgStore) SetCredentials(ctx context.Context, id uuid.UUID, username, hash string) error { + _, err := s.q.Exec(ctx, ` + UPDATE users SET username = $2, password_hash = $3, must_change_credentials = false, + must_change_password = false, token_version = token_version + 1, updated_at = now() + WHERE id = $1`, id, username, hash) + if isUniqueViolation(err) { + return ErrUsernameTaken + } + if err != nil { + return fmt.Errorf("auth: set credentials: %w", err) + } + return nil +} + +// UpdateProfile реализует Store. +func (s *PgStore) UpdateProfile(ctx context.Context, id uuid.UUID, upd ProfileUpdate) (User, error) { + var ( + setName bool + name *string + prefs []byte + err error + ) + if upd.DisplayName != nil { + setName, name = true, *upd.DisplayName + } + if upd.UIPrefs != nil { + if prefs, err = json.Marshal(upd.UIPrefs); err != nil { + return User{}, err + } + } + return scanUser(s.q.QueryRow(ctx, ` + UPDATE users SET + display_name = CASE WHEN $2 THEN $3 ELSE display_name END, + locale = COALESCE($4, locale), + ui_prefs = COALESCE($5::jsonb, ui_prefs), + updated_at = now() + WHERE id = $1 + RETURNING `+userColumns, id, setName, name, upd.Locale, prefs)) +} + +// InsertRefreshToken реализует Store. +func (s *PgStore) InsertRefreshToken(ctx context.Context, t RefreshToken) error { + _, err := s.q.Exec(ctx, ` + INSERT INTO refresh_tokens (id, user_id, family_id, family_started_at, token_hash, device_name, + client_type, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + t.ID, t.UserID, t.FamilyID, t.FamilyStartedAt, t.TokenHash, t.DeviceName, string(t.ClientType), + t.CreatedAt, t.ExpiresAt) + if err != nil { + return fmt.Errorf("auth: insert refresh token: %w", err) + } + return nil +} + +// GetRefreshTokenForUpdate реализует Store. +func (s *PgStore) GetRefreshTokenForUpdate(ctx context.Context, hash []byte) (RefreshToken, error) { + var ( + t RefreshToken + ct string + ) + err := s.q.QueryRow(ctx, ` + SELECT id, user_id, family_id, family_started_at, token_hash, device_name, client_type, + created_at, expires_at, used_at, revoked_at + FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE`, hash). + Scan(&t.ID, &t.UserID, &t.FamilyID, &t.FamilyStartedAt, &t.TokenHash, &t.DeviceName, &ct, + &t.CreatedAt, &t.ExpiresAt, &t.UsedAt, &t.RevokedAt) + if errors.Is(err, pgx.ErrNoRows) { + return RefreshToken{}, ErrNotFound + } + if err != nil { + return RefreshToken{}, fmt.Errorf("auth: get refresh token: %w", err) + } + t.ClientType = ClientType(ct) + return t, nil +} + +// MarkRefreshTokenUsed реализует Store. +func (s *PgStore) MarkRefreshTokenUsed(ctx context.Context, id uuid.UUID, at time.Time) error { + _, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET used_at = $2 WHERE id = $1`, id, at) + if err != nil { + return fmt.Errorf("auth: mark refresh token used: %w", err) + } + return nil +} + +// RevokeFamily реализует Store. +func (s *PgStore) RevokeFamily(ctx context.Context, familyID uuid.UUID, at time.Time) error { + _, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET revoked_at = $2 WHERE family_id = $1 AND revoked_at IS NULL`, + familyID, at) + if err != nil { + return fmt.Errorf("auth: revoke family: %w", err) + } + return nil +} + +// RevokeUserSessions реализует Store. +func (s *PgStore) RevokeUserSessions(ctx context.Context, userID uuid.UUID, at time.Time) error { + _, err := s.q.Exec(ctx, `UPDATE refresh_tokens SET revoked_at = $2 WHERE user_id = $1 AND revoked_at IS NULL`, + userID, at) + if err != nil { + return fmt.Errorf("auth: revoke user sessions: %w", err) + } + return nil +} + +// ListSessions реализует Store. +func (s *PgStore) ListSessions(ctx context.Context, userID uuid.UUID, now time.Time) ([]Session, error) { + rows, err := s.q.Query(ctx, ` + SELECT family_id, device_name, client_type, family_started_at, created_at + FROM refresh_tokens + WHERE user_id = $1 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > $2 + ORDER BY created_at DESC`, userID, now) + if err != nil { + return nil, fmt.Errorf("auth: list sessions: %w", err) + } + out, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (Session, error) { + var ( + ss Session + ct string + ) + err := row.Scan(&ss.ID, &ss.DeviceName, &ct, &ss.CreatedAt, &ss.LastUsedAt) + ss.ClientType = ClientType(ct) + return ss, err + }) + if err != nil { + return nil, fmt.Errorf("auth: list sessions: %w", err) + } + return out, nil +} + +// InsertAudit реализует Store. +func (s *PgStore) InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error { + var payload []byte + if e.Payload != nil { + var err error + if payload, err = json.Marshal(e.Payload); err != nil { + return err + } + } + _, err := s.q.Exec(ctx, ` + INSERT INTO audit_log (id, actor_id, action, target_type, target_id, payload) + VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6)`, id, e.ActorID, e.Action, e.TargetType, e.TargetID, payload) + if err != nil { + return fmt.Errorf("auth: insert audit: %w", err) + } + return nil +} + +// GetSettingBool реализует Store: отсутствующий ключ — false. +func (s *PgStore) GetSettingBool(ctx context.Context, key string) (bool, error) { + var v bool + err := s.q.QueryRow(ctx, `SELECT value::boolean FROM app_settings WHERE key = $1`, key).Scan(&v) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("auth: get setting %s: %w", key, err) + } + return v, nil +} + +// SetSetting реализует Store. +func (s *PgStore) SetSetting(ctx context.Context, key string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + _, err = s.q.Exec(ctx, ` + INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, key, raw) + if err != nil { + return fmt.Errorf("auth: set setting %s: %w", key, err) + } + return nil +} + +var _ Store = (*PgStore)(nil) diff --git a/services/api/internal/auth/policy.go b/services/api/internal/auth/policy.go new file mode 100644 index 0000000..314e378 --- /dev/null +++ b/services/api/internal/auth/policy.go @@ -0,0 +1,94 @@ +package auth + +import "github.com/google/uuid" + +// Матрица прав из docs/auth-and-roles.md. Проверяется в сервисном слое, а не только в middleware. + +// Permission — право, не зависящее от целевого пользователя. +type Permission int + +// Глобальные права. +const ( + PermAdminPanel Permission = iota // открыть админку + PermListUsers // видеть список пользователей + PermManageLibrary // хранилища, сканирование, переиндексация + PermViewLibraryStatus // статус библиотеки (просмотр) + PermSuggestTracks // предлагать треки, видеть свои предложения + PermReviewUploads // очередь, одобрение, правка метаданных, лимиты + PermSystemStatus // статус рекомендательной системы, аудит +) + +var permissions = map[Permission][]Role{ + PermAdminPanel: {RoleModerator, RoleAdmin}, + PermListUsers: {RoleModerator, RoleAdmin}, + PermManageLibrary: {RoleAdmin}, + PermViewLibraryStatus: {RoleModerator, RoleAdmin}, + PermSuggestTracks: {RoleModerator, RoleAdmin}, + PermReviewUploads: {RoleAdmin}, + PermSystemStatus: {RoleAdmin}, +} + +// Can сообщает, есть ли у роли глобальное право. +func Can(r Role, p Permission) bool { + for _, allowed := range permissions[p] { + if allowed == r { + return true + } + } + return false +} + +// UserAction — действие над учётной записью другого пользователя. +type UserAction int + +// Действия над пользователями. +const ( + UserCreate UserAction = iota // создать пользователя с ролью target + UserEdit // изменить отображаемое имя + UserResetPassword // сбросить пароль + UserBlock // заблокировать / разблокировать + UserChangeRole // сменить роль (target — текущая роль) + UserDelete // удалить +) + +// Actor — кто действует (или над кем). ID нужен, чтобы запретить действия над собой. +type Actor struct { + ID uuid.UUID + Role Role +} + +// CanManageUser — может ли actor выполнить action над target. +// Модератор управляет только обычными пользователями; роли и удаление — только админ. +// Блокировать, удалять себя и менять себе роль нельзя никому: так не потерять доступ случайно. +// Правило «последний админ» зависит от данных и проверяется в сервисе. +func CanManageUser(actor, target Actor, action UserAction) bool { + self := actor.ID != uuid.Nil && actor.ID == target.ID + if self && (action == UserBlock || action == UserDelete || action == UserChangeRole) { + return false + } + switch actor.Role { + case RoleAdmin: + return true + case RoleModerator: + switch action { + case UserCreate, UserEdit, UserResetPassword, UserBlock: + return target.Role == RoleUser + default: + return false + } + default: + return false + } +} + +// CanAssignRole — может ли actor выдать роль newRole (при создании или смене роли). +func CanAssignRole(actor Role, newRole Role) bool { + switch actor { + case RoleAdmin: + return newRole.Valid() + case RoleModerator: + return newRole == RoleUser + default: + return false + } +} diff --git a/services/api/internal/auth/policy_test.go b/services/api/internal/auth/policy_test.go new file mode 100644 index 0000000..da5643b --- /dev/null +++ b/services/api/internal/auth/policy_test.go @@ -0,0 +1,91 @@ +package auth + +import ( + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestCan(t *testing.T) { + // Строки матрицы прав из docs/auth-and-roles.md: user, moderator, admin. + matrix := []struct { + perm Permission + name string + want [3]bool + }{ + {PermAdminPanel, "admin panel", [3]bool{false, true, true}}, + {PermListUsers, "list users", [3]bool{false, true, true}}, + {PermManageLibrary, "manage library", [3]bool{false, false, true}}, + {PermViewLibraryStatus, "library status", [3]bool{false, true, true}}, + {PermSuggestTracks, "suggest tracks", [3]bool{false, true, true}}, + {PermReviewUploads, "review uploads", [3]bool{false, false, true}}, + {PermSystemStatus, "system status", [3]bool{false, false, true}}, + } + roles := [3]Role{RoleUser, RoleModerator, RoleAdmin} + for _, row := range matrix { + for i, r := range roles { + t.Run(fmt.Sprintf("%s/%s", row.name, r), func(t *testing.T) { + assert.Equal(t, row.want[i], Can(r, row.perm)) + }) + } + } + assert.False(t, Can(Role("guest"), PermAdminPanel)) +} + +func TestCanManageUser(t *testing.T) { + actions := map[UserAction]string{ + UserCreate: "create", UserEdit: "edit", UserResetPassword: "reset", UserBlock: "block", + UserChangeRole: "role", UserDelete: "delete", + } + // want[actor][target] для каждого действия; порядок ролей — user, moderator, admin. + cases := map[UserAction][3][3]bool{ + UserCreate: {{false, false, false}, {true, false, false}, {true, true, true}}, + UserEdit: {{false, false, false}, {true, false, false}, {true, true, true}}, + UserResetPassword: {{false, false, false}, {true, false, false}, {true, true, true}}, + UserBlock: {{false, false, false}, {true, false, false}, {true, true, true}}, + UserChangeRole: {{false, false, false}, {false, false, false}, {true, true, true}}, + UserDelete: {{false, false, false}, {false, false, false}, {true, true, true}}, + } + roles := [3]Role{RoleUser, RoleModerator, RoleAdmin} + for action, want := range cases { + for ai, ar := range roles { + for ti, tr := range roles { + t.Run(fmt.Sprintf("%s/%s->%s", actions[action], ar, tr), func(t *testing.T) { + actor := Actor{ID: uuid.New(), Role: ar} + target := Actor{ID: uuid.New(), Role: tr} + assert.Equal(t, want[ai][ti], CanManageUser(actor, target, action)) + }) + } + } + } +} + +func TestCanManageUserSelf(t *testing.T) { + admin := Actor{ID: uuid.New(), Role: RoleAdmin} + for _, a := range []UserAction{UserBlock, UserDelete, UserChangeRole} { + assert.False(t, CanManageUser(admin, admin, a), "action %d on self", a) + } + assert.True(t, CanManageUser(admin, admin, UserEdit)) + assert.True(t, CanManageUser(admin, admin, UserResetPassword)) +} + +func TestCanAssignRole(t *testing.T) { + cases := []struct { + actor, role Role + want bool + }{ + {RoleAdmin, RoleAdmin, true}, + {RoleAdmin, RoleModerator, true}, + {RoleAdmin, RoleUser, true}, + {RoleAdmin, Role("root"), false}, + {RoleModerator, RoleUser, true}, + {RoleModerator, RoleModerator, false}, + {RoleModerator, RoleAdmin, false}, + {RoleUser, RoleUser, false}, + } + for _, tc := range cases { + assert.Equal(t, tc.want, CanAssignRole(tc.actor, tc.role), "%s assigns %s", tc.actor, tc.role) + } +} diff --git a/services/api/internal/auth/service.go b/services/api/internal/auth/service.go new file mode 100644 index 0000000..b3955d0 --- /dev/null +++ b/services/api/internal/auth/service.go @@ -0,0 +1,647 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + + "pipu/api/internal/platform/apperr" +) + +// Коды ошибок аутентификации. +const ( + CodeInvalidCredentials = "auth.invalid_credentials" + CodeTooManyAttempts = "auth.too_many_attempts" + CodeUserBlocked = "auth.user_blocked" + CodeUnauthorized = "auth.unauthorized" + CodeTokenExpired = "auth.token_expired" + CodeTokenInvalid = "auth.token_invalid" + CodeTokenRevoked = "auth.token_revoked" + CodeRefreshInvalid = "auth.refresh_invalid" + CodeRefreshReused = "auth.refresh_reused" + CodeRefreshRace = "auth.refresh_race" + CodeCredentialsChangeRequired = "auth.credentials_change_required" + CodePasswordChangeRequired = "auth.password_change_required" + CodeCurrentPasswordRequired = "auth.current_password_required" + CodeCurrentPasswordInvalid = "auth.current_password_invalid" + CodeBootstrapNotRequired = "auth.bootstrap_not_required" + CodeSessionNotFound = "auth.session_not_found" + CodeUsernameTaken = "validation.username_taken" +) + +// Ключ app_settings: первый вход администратора завершён. +const settingBootstrapCompleted = "bootstrap.completed" + +// Config — параметры сервиса. +type Config struct { + RefreshTTL time.Duration // скользящий срок refresh-токена + RefreshMaxAge time.Duration // абсолютный срок семьи токенов + // RefreshRaceWindow — сколько после ротации повтор старого токена считается гонкой + // (две вкладки обновили токен одновременно), а не кражей. + RefreshRaceWindow time.Duration + + LockThreshold int // неудачных входов до блокировки + LockBase time.Duration // первая блокировка, дальше удваивается + LockMax time.Duration + + LoginLimitPerUser int // попыток в минуту на IP + логин + LoginLimitPerIP int // попыток в минуту на IP + + BootstrapUsername string + BootstrapPassword string // пусто — сгенерировать + + SupportedLocales []string +} + +// DefaultConfig — значения из docs/auth-and-roles.md. +func DefaultConfig() Config { + return Config{ + RefreshTTL: 30 * 24 * time.Hour, + RefreshMaxAge: 180 * 24 * time.Hour, + RefreshRaceWindow: 10 * time.Second, + LockThreshold: 5, + LockBase: 5 * time.Minute, + LockMax: time.Hour, + LoginLimitPerUser: 5, + LoginLimitPerIP: 30, + BootstrapUsername: "admin", + SupportedLocales: []string{"ru", "en"}, + } +} + +// Service — бизнес-логика входа, токенов и профиля. +type Service struct { + store Store + hasher *Hasher + tokens *TokenIssuer + limiter Limiter + clock Clock + ids IDGen + log *slog.Logger + cfg Config + + // dummyHash проверяется для несуществующих логинов, чтобы время ответа не выдавало их отсутствие. + dummyHash string +} + +// NewService создаёт сервис. +func NewService(store Store, hasher *Hasher, tokens *TokenIssuer, limiter Limiter, clock Clock, ids IDGen, + log *slog.Logger, cfg Config, +) (*Service, error) { + if log == nil { + log = slog.New(slog.DiscardHandler) + } + dummy, err := hasher.Hash("pipu-dummy-password") + if err != nil { + return nil, err + } + return &Service{ + store: store, hasher: hasher, tokens: tokens, limiter: limiter, clock: clock, ids: ids, log: log, cfg: cfg, + dummyHash: dummy, + }, nil +} + +// Tokens — результат входа или ротации. +type Tokens struct { + Access string + ExpiresIn time.Duration + Refresh string + ClientType ClientType + User User +} + +// LoginInput — данные формы входа. +type LoginInput struct { + Username string + Password string + ClientType ClientType + DeviceName string + IP string +} + +func invalidCredentials() error { + return apperr.Unauthorized(CodeInvalidCredentials, "invalid username or password") +} + +// Login проверяет логин и пароль и открывает новую сессию. +func (s *Service) Login(ctx context.Context, in LoginInput) (Tokens, error) { + if !in.ClientType.Valid() { + return Tokens{}, apperr.Validation("client_type", "validation.client_type_invalid", "unknown client type", nil) + } + username := NormalizeUsername(in.Username) + if err := s.checkRateLimit(ctx, in.IP, username); err != nil { + return Tokens{}, err + } + if len(in.Password) > MaxPassword*4 { + return Tokens{}, invalidCredentials() + } + + u, err := s.store.GetUserByUsername(ctx, username) + if errors.Is(err, ErrNotFound) { + _, _, _ = s.hasher.Verify(in.Password, s.dummyHash) + return Tokens{}, invalidCredentials() + } + if err != nil { + return Tokens{}, err + } + + now := s.clock.Now() + if u.LockedUntil != nil && now.Before(*u.LockedUntil) { + _, _, _ = s.hasher.Verify(in.Password, s.dummyHash) + return Tokens{}, invalidCredentials() + } + ok, rehash, err := s.hasher.Verify(in.Password, u.PasswordHash) + if err != nil { + return Tokens{}, err + } + if !ok { + failed := u.FailedLoginCount + 1 + var lockedUntil *time.Time + if failed >= s.cfg.LockThreshold { + until := now.Add(s.lockDuration(failed)) + lockedUntil = &until + } + if err := s.store.RecordLoginFailure(ctx, u.ID, failed, lockedUntil); err != nil { + return Tokens{}, err + } + return Tokens{}, invalidCredentials() + } + if u.IsBlocked { + return Tokens{}, apperr.Forbidden(CodeUserBlocked, "user is blocked") + } + + var newHash *string + if rehash { + h, err := s.hasher.Hash(in.Password) + if err != nil { + return Tokens{}, err + } + newHash = &h + } + var out Tokens + err = s.store.WithTx(ctx, func(tx Store) error { + if err := tx.RecordLoginSuccess(ctx, u.ID, now, newHash); err != nil { + return err + } + out, err = s.openSession(ctx, tx, u, in.ClientType, in.DeviceName) + return err + }) + return out, err +} + +// lockDuration — блокировка после failed неудачных попыток: LockBase, затем вдвое больше за каждую +// следующую неудачу, но не более LockMax. +func (s *Service) lockDuration(failed int) time.Duration { + d := s.cfg.LockBase + for i := s.cfg.LockThreshold; i < failed && d < s.cfg.LockMax; i++ { + d *= 2 + } + return min(d, s.cfg.LockMax) +} + +func (s *Service) checkRateLimit(ctx context.Context, ip, username string) error { + if s.limiter == nil { + return nil + } + checks := []struct { + key string + limit int + }{ + {"login:ip:" + ip, s.cfg.LoginLimitPerIP}, + {"login:user:" + ip + ":" + username, s.cfg.LoginLimitPerUser}, + } + for _, c := range checks { + ok, retry, err := s.limiter.Allow(ctx, c.key, c.limit, time.Minute) + if err != nil { + // Redis недоступен — не запираем всех снаружи: остаётся блокировка учётки по счётчику в БД. + s.log.WarnContext(ctx, "login rate limiter unavailable", "err", err) + return nil + } + if !ok { + return apperr.TooManyRequests(CodeTooManyAttempts, retry) + } + } + return nil +} + +// openSession создаёт новую семью refresh-токенов и выдаёт пару. +func (s *Service) openSession(ctx context.Context, st Store, u User, ct ClientType, device string) (Tokens, error) { + now := s.clock.Now() + return s.issue(ctx, st, u, RefreshToken{ + FamilyID: s.ids.New(), + FamilyStartedAt: now, + DeviceName: deviceName(device), + ClientType: ct, + }) +} + +// issue записывает новый refresh-токен семьи prev и подписывает access-токен. +func (s *Service) issue(ctx context.Context, st Store, u User, prev RefreshToken) (Tokens, error) { + now := s.clock.Now() + refresh, hash, err := NewRefreshToken() + if err != nil { + return Tokens{}, err + } + expires := now.Add(s.cfg.RefreshTTL) + if hardLimit := prev.FamilyStartedAt.Add(s.cfg.RefreshMaxAge); expires.After(hardLimit) { + expires = hardLimit + } + rt := RefreshToken{ + ID: s.ids.New(), + UserID: u.ID, + FamilyID: prev.FamilyID, + FamilyStartedAt: prev.FamilyStartedAt, + TokenHash: hash, + DeviceName: prev.DeviceName, + ClientType: prev.ClientType, + CreatedAt: now, + ExpiresAt: expires, + } + if err := st.InsertRefreshToken(ctx, rt); err != nil { + return Tokens{}, err + } + access, err := s.tokens.Issue(u.ID, u.Role, rt.FamilyID, u.TokenVersion) + if err != nil { + return Tokens{}, err + } + return Tokens{Access: access, ExpiresIn: s.tokens.TTL(), Refresh: refresh, ClientType: rt.ClientType, User: u}, nil +} + +func deviceName(s string) *string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + if utf8.RuneCountInString(s) > 100 { + s = string([]rune(s)[:100]) + } + return &s +} + +// Refresh ротирует refresh-токен: старый помечается использованным, выдаётся новый в той же семье. +func (s *Service) Refresh(ctx context.Context, token string) (Tokens, error) { + if token == "" { + return Tokens{}, apperr.Unauthorized(CodeRefreshInvalid, "refresh token missing") + } + now := s.clock.Now() + var ( + out Tokens + reused bool + familyID uuid.UUID + ) + err := s.store.WithTx(ctx, func(tx Store) error { + rt, err := tx.GetRefreshTokenForUpdate(ctx, HashRefreshToken(token)) + if errors.Is(err, ErrNotFound) { + return apperr.Unauthorized(CodeRefreshInvalid, "unknown refresh token") + } + if err != nil { + return err + } + if rt.RevokedAt != nil { + return apperr.Unauthorized(CodeRefreshInvalid, "refresh token revoked") + } + if rt.UsedAt != nil { + if now.Sub(*rt.UsedAt) <= s.cfg.RefreshRaceWindow { + return apperr.Unauthorized(CodeRefreshRace, "refresh token was just rotated") + } + reused, familyID = true, rt.FamilyID + return nil + } + if !now.Before(rt.ExpiresAt) { + return apperr.Unauthorized(CodeRefreshInvalid, "refresh token expired") + } + u, err := tx.GetUserByID(ctx, rt.UserID) + if err != nil { + return err + } + if u.IsBlocked { + return apperr.Forbidden(CodeUserBlocked, "user is blocked") + } + if err := tx.MarkRefreshTokenUsed(ctx, rt.ID, now); err != nil { + return err + } + out, err = s.issue(ctx, tx, u, rt) + return err + }) + if err != nil { + return Tokens{}, err + } + if reused { + // Токен уже обменяли — его украли или скопировали. Отзываем всю семью вне транзакции чтения. + s.log.WarnContext(ctx, "refresh token reuse detected, family revoked", "family_id", familyID) + if err := s.store.RevokeFamily(ctx, familyID, now); err != nil { + return Tokens{}, err + } + return Tokens{}, apperr.Unauthorized(CodeRefreshReused, "refresh token reuse") + } + return out, nil +} + +// Authenticate проверяет access-токен и актуальное состояние пользователя. +func (s *Service) Authenticate(ctx context.Context, access string) (Principal, error) { + claims, err := s.tokens.Parse(access) + switch { + case errors.Is(err, ErrTokenExpired): + return Principal{}, apperr.Unauthorized(CodeTokenExpired, "access token expired") + case err != nil: + return Principal{}, apperr.Unauthorized(CodeTokenInvalid, "access token invalid") + } + u, err := s.store.GetUserByID(ctx, claims.UserID) + if errors.Is(err, ErrNotFound) { + return Principal{}, apperr.Unauthorized(CodeTokenRevoked, "user not found") + } + if err != nil { + return Principal{}, err + } + if u.IsBlocked { + return Principal{}, apperr.Unauthorized(CodeUserBlocked, "user is blocked") + } + if u.TokenVersion != claims.TokenVersion { + return Principal{}, apperr.Unauthorized(CodeTokenRevoked, "token version changed") + } + return Principal{ + UserID: u.ID, + Role: u.Role, + SessionID: claims.SessionID, + MustChangePassword: u.MustChangePassword, + MustChangeCredentials: u.MustChangeCredentials, + }, nil +} + +// Logout отзывает текущую сессию. +func (s *Service) Logout(ctx context.Context, p Principal) error { + return s.store.RevokeFamily(ctx, p.SessionID, s.clock.Now()) +} + +// LogoutAll отзывает все сессии пользователя. +func (s *Service) LogoutAll(ctx context.Context, p Principal) error { + return s.store.RevokeUserSessions(ctx, p.UserID, s.clock.Now()) +} + +// Me возвращает профиль текущего пользователя. +func (s *Service) Me(ctx context.Context, p Principal) (User, error) { + return s.store.GetUserByID(ctx, p.UserID) +} + +// MePatch — изменения профиля из PATCH /me. nil — не менять; DisplayName "" — сбросить. +type MePatch struct { + DisplayName *string + Locale *string + UIPrefs *UIPrefs +} + +// UpdateMe меняет имя, язык и настройки интерфейса. +func (s *Service) UpdateMe(ctx context.Context, p Principal, patch MePatch) (User, error) { + var upd ProfileUpdate + if patch.DisplayName != nil { + name := strings.TrimSpace(*patch.DisplayName) + if utf8.RuneCountInString(name) > MaxDisplayName { + return User{}, apperr.Validation("display_name", CodeDisplayNameTooLong, "display name too long", + map[string]any{"max": MaxDisplayName}) + } + var v *string + if name != "" { + v = &name + } + upd.DisplayName = &v + } + if patch.Locale != nil { + if !contains(s.cfg.SupportedLocales, *patch.Locale) { + return User{}, apperr.Validation("locale", CodeLocaleUnsupported, "unsupported locale", + map[string]any{"supported": s.cfg.SupportedLocales}) + } + upd.Locale = patch.Locale + } + if patch.UIPrefs != nil { + if err := ValidateUIPrefs(*patch.UIPrefs); err != nil { + return User{}, err + } + u, err := s.store.GetUserByID(ctx, p.UserID) + if err != nil { + return User{}, err + } + merged := MergeUIPrefs(u.UIPrefs, *patch.UIPrefs) + upd.UIPrefs = &merged + } + return s.store.UpdateProfile(ctx, p.UserID, upd) +} + +func contains(list []string, v string) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} + +// ChangePassword меняет пароль, отзывает все сессии и открывает новую на текущем устройстве. +// current не нужен, если пароль временный (must_change_password). +func (s *Service) ChangePassword(ctx context.Context, p Principal, current *string, newPassword string) (Tokens, error) { + u, err := s.store.GetUserByID(ctx, p.UserID) + if err != nil { + return Tokens{}, err + } + if u.MustChangeCredentials { + return Tokens{}, apperr.Forbidden(CodeCredentialsChangeRequired, "use bootstrap credentials endpoint") + } + if !u.MustChangePassword { + if current == nil || *current == "" { + return Tokens{}, apperr.Validation("current_password", CodeCurrentPasswordRequired, "current password required", nil) + } + ok, _, err := s.hasher.Verify(*current, u.PasswordHash) + if err != nil { + return Tokens{}, err + } + if !ok { + return Tokens{}, apperr.Validation("current_password", CodeCurrentPasswordInvalid, "current password is wrong", nil) + } + } + if err := s.checkNewPassword(u, u.Username, newPassword); err != nil { + return Tokens{}, err + } + hash, err := s.hasher.Hash(newPassword) + if err != nil { + return Tokens{}, err + } + return s.replaceSessions(ctx, p, u, func(tx Store) error { return tx.SetPassword(ctx, u.ID, hash) }, nil) +} + +// checkNewPassword — политика паролей плюс запрет оставить прежний пароль. +func (s *Service) checkNewPassword(u User, username, newPassword string) error { + if err := ValidatePassword("new_password", newPassword, username, u.Role); err != nil { + return err + } + same, _, err := s.hasher.Verify(newPassword, u.PasswordHash) + if err != nil { + return err + } + if same { + return apperr.Validation("new_password", CodePasswordUnchanged, "new password equals current", nil) + } + return nil +} + +// replaceSessions в одной транзакции применяет change, отзывает все сессии пользователя +// и открывает новую с тем же устройством, что у текущей. +func (s *Service) replaceSessions(ctx context.Context, p Principal, u User, change func(Store) error, + audit *AuditEntry, +) (Tokens, error) { + now := s.clock.Now() + var out Tokens + err := s.store.WithTx(ctx, func(tx Store) error { + sessions, err := tx.ListSessions(ctx, u.ID, now) + if err != nil { + return err + } + ct, device := ClientWeb, "" + for _, ss := range sessions { + if ss.ID == p.SessionID { + ct = ss.ClientType + if ss.DeviceName != nil { + device = *ss.DeviceName + } + } + } + if err := change(tx); err != nil { + return err + } + if err := tx.RevokeUserSessions(ctx, u.ID, now); err != nil { + return err + } + if audit != nil { + if err := tx.InsertAudit(ctx, s.ids.New(), *audit); err != nil { + return err + } + } + // Перечитываем: change увеличил token_version, новый access-токен должен нести его. + fresh, err := tx.GetUserByID(ctx, u.ID) + if err != nil { + return err + } + out, err = s.openSession(ctx, tx, fresh, ct, device) + return err + }) + return out, err +} + +// SetBootstrapCredentials — первый вход администратора: новые логин и пароль. +func (s *Service) SetBootstrapCredentials(ctx context.Context, p Principal, newUsername, newPassword string) (Tokens, error) { + u, err := s.store.GetUserByID(ctx, p.UserID) + if err != nil { + return Tokens{}, err + } + if !u.MustChangeCredentials { + return Tokens{}, apperr.Conflict(CodeBootstrapNotRequired, "credentials already changed") + } + username := NormalizeUsername(newUsername) + if err := ValidateUsername("new_username", username); err != nil { + return Tokens{}, err + } + if err := s.checkNewPassword(u, username, newPassword); err != nil { + return Tokens{}, err + } + hash, err := s.hasher.Hash(newPassword) + if err != nil { + return Tokens{}, err + } + audit := &AuditEntry{ + ActorID: &u.ID, Action: "user.bootstrap_credentials", TargetType: "user", TargetID: &u.ID, + Payload: map[string]any{"username": username}, + } + out, err := s.replaceSessions(ctx, p, u, func(tx Store) error { + if err := tx.SetCredentials(ctx, u.ID, username, hash); err != nil { + return err + } + return tx.SetSetting(ctx, settingBootstrapCompleted, true) + }, audit) + if errors.Is(err, ErrUsernameTaken) { + return Tokens{}, apperr.Validation("new_username", CodeUsernameTaken, "username is taken", nil) + } + return out, err +} + +// ListSessions — активные сессии пользователя, новые сверху. +func (s *Service) ListSessions(ctx context.Context, p Principal) ([]Session, error) { + return s.store.ListSessions(ctx, p.UserID, s.clock.Now()) +} + +// RevokeSession завершает одну свою сессию. +func (s *Service) RevokeSession(ctx context.Context, p Principal, id uuid.UUID) error { + now := s.clock.Now() + sessions, err := s.store.ListSessions(ctx, p.UserID, now) + if err != nil { + return err + } + for _, ss := range sessions { + if ss.ID == id { + return s.store.RevokeFamily(ctx, id, now) + } + } + return apperr.NotFound(CodeSessionNotFound, "session not found") +} + +// BootstrapRequired — первый вход администратора ещё не завершён. +func (s *Service) BootstrapRequired(ctx context.Context) (bool, error) { + done, err := s.store.GetSettingBool(ctx, settingBootstrapCompleted) + return !done, err +} + +// Bootstrap создаёт первого администратора, если его нет и первый запуск не завершён. +// Сгенерированный пароль печатается в лог один раз. +func (s *Service) Bootstrap(ctx context.Context) error { + done, err := s.store.GetSettingBool(ctx, settingBootstrapCompleted) + if err != nil || done { + return err + } + admins, err := s.store.CountAdmins(ctx) + if err != nil || admins > 0 { + return err + } + username := NormalizeUsername(s.cfg.BootstrapUsername) + if err := ValidateUsername("BOOTSTRAP_ADMIN_USERNAME", username); err != nil { + return fmt.Errorf("auth: bootstrap: %w", err) + } + password, generated := s.cfg.BootstrapPassword, false + if password == "" { + if password, err = GeneratePassword(20); err != nil { + return err + } + generated = true + } + hash, err := s.hasher.Hash(password) + if err != nil { + return err + } + u := User{ + ID: s.ids.New(), Username: username, PasswordHash: hash, Role: RoleAdmin, Locale: "ru", + MustChangeCredentials: true, CreatedAt: s.clock.Now(), + } + err = s.store.WithTx(ctx, func(tx Store) error { + if err := tx.CreateUser(ctx, u, nil); err != nil { + return err + } + return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{ + Action: "user.bootstrap_create", TargetType: "user", TargetID: &u.ID, + Payload: map[string]any{"username": username}, + }) + }) + if err != nil { + return fmt.Errorf("auth: bootstrap: %w", err) + } + if generated { + // Единственное место, где пароль попадает в лог: админ увидит его в `docker compose logs api`. + s.log.Warn("bootstrap admin created: sign in and change the credentials", + "username", username, "password", password) + } else { + s.log.Warn("bootstrap admin created with BOOTSTRAP_ADMIN_PASSWORD: sign in and change the credentials", + "username", username) + } + return nil +} diff --git a/services/api/internal/auth/store.go b/services/api/internal/auth/store.go new file mode 100644 index 0000000..c9ea7db --- /dev/null +++ b/services/api/internal/auth/store.go @@ -0,0 +1,79 @@ +package auth + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" +) + +// Ошибки хранилища. +var ( + ErrNotFound = errors.New("auth: not found") + ErrUsernameTaken = errors.New("auth: username taken") +) + +// ProfileUpdate — изменяемые пользователем поля профиля. nil — не менять. +type ProfileUpdate struct { + DisplayName **string // *nil — сбросить имя + Locale *string + UIPrefs *UIPrefs +} + +// Store — доступ к users, refresh_tokens, audit_log и app_settings. +type Store interface { + // WithTx выполняет fn в транзакции; Store внутри fn работает в ней же. + WithTx(ctx context.Context, fn func(Store) error) error + + CountAdmins(ctx context.Context) (int, error) + CreateUser(ctx context.Context, u User, createdBy *uuid.UUID) error + GetUserByID(ctx context.Context, id uuid.UUID) (User, error) + GetUserByUsername(ctx context.Context, username string) (User, error) + RecordLoginFailure(ctx context.Context, id uuid.UUID, failedCount int, lockedUntil *time.Time) error + // RecordLoginSuccess сбрасывает счётчик неудач; newHash != nil — пересчитанный хэш пароля. + RecordLoginSuccess(ctx context.Context, id uuid.UUID, at time.Time, newHash *string) error + // SetPassword меняет хэш, снимает must_change_password и увеличивает token_version. + SetPassword(ctx context.Context, id uuid.UUID, hash string) error + // SetCredentials меняет логин и хэш, снимает must_change_credentials и увеличивает token_version. + SetCredentials(ctx context.Context, id uuid.UUID, username, hash string) error + UpdateProfile(ctx context.Context, id uuid.UUID, upd ProfileUpdate) (User, error) + + InsertRefreshToken(ctx context.Context, t RefreshToken) error + // GetRefreshTokenForUpdate находит токен по хэшу и блокирует строку до конца транзакции. + GetRefreshTokenForUpdate(ctx context.Context, hash []byte) (RefreshToken, error) + MarkRefreshTokenUsed(ctx context.Context, id uuid.UUID, at time.Time) error + RevokeFamily(ctx context.Context, familyID uuid.UUID, at time.Time) error + // RevokeUserSessions отзывает все семьи пользователя. + RevokeUserSessions(ctx context.Context, userID uuid.UUID, at time.Time) error + // ListSessions — активные семьи пользователя: есть неиспользованный, неотозванный, не истёкший токен. + ListSessions(ctx context.Context, userID uuid.UUID, now time.Time) ([]Session, error) + + InsertAudit(ctx context.Context, id uuid.UUID, e AuditEntry) error + GetSettingBool(ctx context.Context, key string) (bool, error) + SetSetting(ctx context.Context, key string, value any) error +} + +// Limiter ограничивает частоту попыток входа. +type Limiter interface { + // Allow учитывает попытку по ключу. retryAfter — секунды до сброса окна, если попытка отклонена. + Allow(ctx context.Context, key string, limit int, window time.Duration) (ok bool, retryAfter int, err error) +} + +// Clock — источник времени (подменяется в тестах). +type Clock interface{ Now() time.Time } + +// IDGen — генератор UUIDv7. +type IDGen interface{ New() uuid.UUID } + +// SystemClock — реальное время в UTC. +type SystemClock struct{} + +// Now возвращает текущее время. +func (SystemClock) Now() time.Time { return time.Now().UTC() } + +// UUIDv7 — генератор сортируемых по времени UUID. +type UUIDv7 struct{} + +// New возвращает новый UUIDv7. +func (UUIDv7) New() uuid.UUID { return uuid.Must(uuid.NewV7()) } diff --git a/services/api/internal/auth/token.go b/services/api/internal/auth/token.go new file mode 100644 index 0000000..6be251f --- /dev/null +++ b/services/api/internal/auth/token.go @@ -0,0 +1,169 @@ +package auth + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// Ошибки проверки access-токена. +var ( + ErrTokenExpired = errors.New("auth: token expired") + ErrTokenInvalid = errors.New("auth: token invalid") +) + +// AccessClaims — содержимое access-токена. +type AccessClaims struct { + UserID uuid.UUID + Role Role + SessionID uuid.UUID + TokenVersion int + IssuedAt time.Time + ExpiresAt time.Time +} + +type jwtClaims struct { + Role string `json:"role"` + SID string `json:"sid"` + Ver int `json:"ver"` + jwt.RegisteredClaims +} + +// TokenIssuer подписывает и проверяет access-токены (JWT, EdDSA). Подписывает активным ключом, +// проверяет активным и предыдущими (по kid) — так ключ можно сменить, не разлогинив всех. +type TokenIssuer struct { + kid string + key ed25519.PrivateKey + verify map[string]ed25519.PublicKey + ttl time.Duration + now func() time.Time +} + +// NewTokenIssuer создаёт issuer. previous — ключи, которыми подписаны ещё живые токены. +func NewTokenIssuer(active ed25519.PrivateKey, previous []ed25519.PrivateKey, ttl time.Duration, now func() time.Time) *TokenIssuer { + t := &TokenIssuer{key: active, ttl: ttl, now: now, verify: map[string]ed25519.PublicKey{}} + t.kid = KeyID(active.Public().(ed25519.PublicKey)) + t.verify[t.kid] = active.Public().(ed25519.PublicKey) + for _, k := range previous { + pub := k.Public().(ed25519.PublicKey) + t.verify[KeyID(pub)] = pub + } + return t +} + +// TTL — срок жизни access-токена. +func (t *TokenIssuer) TTL() time.Duration { return t.ttl } + +// KeyID — идентификатор ключа: первые 8 байт SHA-256 публичного ключа. +func KeyID(pub ed25519.PublicKey) string { + sum := sha256.Sum256(pub) + return hex.EncodeToString(sum[:8]) +} + +// Issue выпускает access-токен. +func (t *TokenIssuer) Issue(userID uuid.UUID, role Role, sessionID uuid.UUID, tokenVersion int) (string, error) { + now := t.now() + tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwtClaims{ + Role: string(role), + SID: sessionID.String(), + Ver: tokenVersion, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID.String(), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(t.ttl)), + }, + }) + tok.Header["kid"] = t.kid + s, err := tok.SignedString(t.key) + if err != nil { + return "", fmt.Errorf("auth: sign token: %w", err) + } + return s, nil +} + +// Parse проверяет подпись и срок токена. +func (t *TokenIssuer) Parse(s string) (AccessClaims, error) { + var c jwtClaims + _, err := jwt.ParseWithClaims(s, &c, func(tok *jwt.Token) (any, error) { + kid, _ := tok.Header["kid"].(string) + pub, ok := t.verify[kid] + if !ok { + return nil, ErrTokenInvalid + } + return pub, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}), jwt.WithTimeFunc(t.now), + jwt.WithExpirationRequired(), jwt.WithIssuedAt()) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return AccessClaims{}, ErrTokenExpired + } + return AccessClaims{}, ErrTokenInvalid + } + uid, err1 := uuid.Parse(c.Subject) + sid, err2 := uuid.Parse(c.SID) + if err1 != nil || err2 != nil || !Role(c.Role).Valid() { + return AccessClaims{}, ErrTokenInvalid + } + return AccessClaims{ + UserID: uid, Role: Role(c.Role), SessionID: sid, TokenVersion: c.Ver, + IssuedAt: c.IssuedAt.Time, ExpiresAt: c.ExpiresAt.Time, + }, nil +} + +// ParsePrivateKey читает ключ Ed25519 из base64 (стандартный или url-алфавит): 32-байтовый seed +// или 64-байтовый приватный ключ. +func ParsePrivateKey(s string) (ed25519.PrivateKey, error) { + s = strings.TrimSpace(s) + var raw []byte + var err error + for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { + if raw, err = enc.DecodeString(s); err == nil { + break + } + } + if err != nil { + return nil, fmt.Errorf("auth: jwt key is not base64: %w", err) + } + switch len(raw) { + case ed25519.SeedSize: + return ed25519.NewKeyFromSeed(raw), nil + case ed25519.PrivateKeySize: + return ed25519.PrivateKey(raw), nil + default: + return nil, fmt.Errorf("auth: jwt key must be %d or %d bytes, got %d", ed25519.SeedSize, ed25519.PrivateKeySize, len(raw)) + } +} + +// GenerateKey создаёт новый ключ Ed25519. +func GenerateKey() (ed25519.PrivateKey, error) { + _, k, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("auth: generate key: %w", err) + } + return k, nil +} + +// NewRefreshToken возвращает случайный refresh-токен (32 байта, base64url) и его SHA-256 для БД. +func NewRefreshToken() (token string, hash []byte, err error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", nil, fmt.Errorf("auth: refresh token: %w", err) + } + token = base64.RawURLEncoding.EncodeToString(buf) + return token, HashRefreshToken(token), nil +} + +// HashRefreshToken — SHA-256 от строки токена. +func HashRefreshToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} diff --git a/services/api/internal/auth/validate.go b/services/api/internal/auth/validate.go new file mode 100644 index 0000000..7348853 --- /dev/null +++ b/services/api/internal/auth/validate.go @@ -0,0 +1,146 @@ +package auth + +import ( + _ "embed" + "regexp" + "strings" + "unicode/utf8" + + "pipu/api/internal/platform/apperr" +) + +// Коды ошибок валидации (переводятся клиентом, packages/i18n). +const ( + CodePasswordTooShort = "validation.password_too_short" + CodePasswordTooLong = "validation.password_too_long" + CodePasswordSameAsUsername = "validation.password_same_as_username" + CodePasswordTooCommon = "validation.password_too_common" + CodePasswordUnchanged = "validation.password_unchanged" + CodeUsernameInvalid = "validation.username_invalid" + CodeLocaleUnsupported = "validation.locale_unsupported" + CodeDisplayNameTooLong = "validation.display_name_too_long" + CodeUIPrefsInvalid = "validation.ui_prefs_invalid" +) + +// Пределы длины. Верхний предел пароля защищает argon2 от гигантских входов. +const ( + MinPasswordUser = 8 + MinPasswordPrivileged = 12 + MaxPassword = 256 + MaxDisplayName = 64 +) + +var usernameRe = regexp.MustCompile(`^[a-z0-9._-]{3,32}$`) + +// NormalizeUsername приводит логин к нижнему регистру без пробелов по краям. +func NormalizeUsername(s string) string { return strings.ToLower(strings.TrimSpace(s)) } + +// ValidateUsername проверяет нормализованный логин: 3..32 символа [a-z0-9._-]. +func ValidateUsername(field, username string) error { + if !usernameRe.MatchString(username) { + return apperr.Validation(field, CodeUsernameInvalid, "username must be 3-32 chars of [a-z0-9._-]", + map[string]any{"min": 3, "max": 32}) + } + return nil +} + +// MinPasswordLen — минимальная длина пароля для роли. +func MinPasswordLen(r Role) int { + if r == RoleUser { + return MinPasswordUser + } + return MinPasswordPrivileged +} + +// ValidatePassword проверяет новый пароль по политике: длина по роли, не совпадает с логином, не из списка частых. +func ValidatePassword(field, password, username string, role Role) error { + n := utf8.RuneCountInString(password) + if minLen := MinPasswordLen(role); n < minLen { + return apperr.Validation(field, CodePasswordTooShort, "password is too short", map[string]any{"min": minLen}) + } + if n > MaxPassword { + return apperr.Validation(field, CodePasswordTooLong, "password is too long", map[string]any{"max": MaxPassword}) + } + lower := strings.ToLower(password) + if username != "" && lower == strings.ToLower(username) { + return apperr.Validation(field, CodePasswordSameAsUsername, "password equals username", nil) + } + if _, ok := commonPasswords[lower]; ok { + return apperr.Validation(field, CodePasswordTooCommon, "password is too common", nil) + } + return nil +} + +//go:embed common_passwords.txt +var commonPasswordsTxt string + +var commonPasswords = func() map[string]struct{} { + m := make(map[string]struct{}) + for line := range strings.SplitSeq(commonPasswordsTxt, "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + m[strings.ToLower(line)] = struct{}{} + } + } + return m +}() + +var uiPrefEnums = map[string][]string{ + "theme": {"dark", "light", "system"}, + "wave_variant": {"orb", "liquid"}, + "web_player": {"bar", "island"}, + "mobile_player": {"cover", "wave"}, +} + +// ValidateUIPrefs проверяет значения перечислений и диапазон кроссфейда. +func ValidateUIPrefs(p UIPrefs) error { + check := func(name string, v *string) error { + if v == nil { + return nil + } + for _, allowed := range uiPrefEnums[name] { + if *v == allowed { + return nil + } + } + return apperr.Validation("ui_prefs."+name, CodeUIPrefsInvalid, "unknown value", nil) + } + for name, v := range map[string]*string{ + "theme": p.Theme, "wave_variant": p.WaveVariant, "web_player": p.WebPlayer, "mobile_player": p.MobilePlayer, + } { + if err := check(name, v); err != nil { + return err + } + } + if p.CrossfadeS != nil && (*p.CrossfadeS < 0 || *p.CrossfadeS > 6) { + return apperr.Validation("ui_prefs.crossfade_s", CodeUIPrefsInvalid, "crossfade must be 0..6", + map[string]any{"min": 0, "max": 6}) + } + return nil +} + +// MergeUIPrefs накладывает заданные поля patch на base. +func MergeUIPrefs(base, patch UIPrefs) UIPrefs { + if patch.Theme != nil { + base.Theme = patch.Theme + } + if patch.WaveVariant != nil { + base.WaveVariant = patch.WaveVariant + } + if patch.WebPlayer != nil { + base.WebPlayer = patch.WebPlayer + } + if patch.MobilePlayer != nil { + base.MobilePlayer = patch.MobilePlayer + } + if patch.Ambient != nil { + base.Ambient = patch.Ambient + } + if patch.CrossfadeS != nil { + base.CrossfadeS = patch.CrossfadeS + } + if patch.Normalize != nil { + base.Normalize = patch.Normalize + } + return base +} diff --git a/services/api/internal/platform/apperr/apperr.go b/services/api/internal/platform/apperr/apperr.go new file mode 100644 index 0000000..87a28a5 --- /dev/null +++ b/services/api/internal/platform/apperr/apperr.go @@ -0,0 +1,71 @@ +// Package apperr — доменные ошибки с машинным кодом для i18n. В problem+json их переводит +// единственное место — httpx.ResponseError; модули только возвращают *Error. +package apperr + +import ( + "fmt" + "net/http" +) + +// FieldError — ошибка конкретного поля запроса. +type FieldError struct { + Field string `json:"field"` + Code string `json:"code"` +} + +// Error — ошибка, которую можно показать клиенту: HTTP-статус, код и параметры для перевода. +type Error struct { + Status int + Code string + Detail string + Params map[string]any + Fields []FieldError + // RetryAfter — секунды для заголовка Retry-After (429). + RetryAfter int +} + +func (e *Error) Error() string { + if e.Detail != "" { + return fmt.Sprintf("%s: %s", e.Code, e.Detail) + } + return e.Code +} + +// Is сравнивает ошибки по коду: errors.Is(err, apperr.Unauthorized("auth.invalid_credentials", "")). +func (e *Error) Is(target error) bool { + t, ok := target.(*Error) + return ok && t.Code == e.Code +} + +// New создаёт ошибку с произвольным статусом. +func New(status int, code, detail string) *Error { + return &Error{Status: status, Code: code, Detail: detail} +} + +// Unauthorized — 401. +func Unauthorized(code, detail string) *Error { return New(http.StatusUnauthorized, code, detail) } + +// Forbidden — 403. +func Forbidden(code, detail string) *Error { return New(http.StatusForbidden, code, detail) } + +// NotFound — 404. +func NotFound(code, detail string) *Error { return New(http.StatusNotFound, code, detail) } + +// Conflict — 409. +func Conflict(code, detail string) *Error { return New(http.StatusConflict, code, detail) } + +// Validation — 422 с ошибкой одного поля. +func Validation(field, code, detail string, params map[string]any) *Error { + return &Error{ + Status: http.StatusUnprocessableEntity, + Code: code, + Detail: detail, + Params: params, + Fields: []FieldError{{Field: field, Code: code}}, + } +} + +// TooManyRequests — 429 с Retry-After. +func TooManyRequests(code string, retryAfterSec int) *Error { + return &Error{Status: http.StatusTooManyRequests, Code: code, Detail: "rate limited", RetryAfter: retryAfterSec} +} diff --git a/services/api/internal/platform/config/config.go b/services/api/internal/platform/config/config.go index 612c64e..56c90e2 100644 --- a/services/api/internal/platform/config/config.go +++ b/services/api/internal/platform/config/config.go @@ -19,6 +19,18 @@ type API struct { SupportedLocales []string `env:"SUPPORTED_LOCALES" envDefault:"ru,en" envSeparator:","` MinAndroidVersionCode int `env:"MIN_ANDROID_VERSION_CODE" envDefault:"0"` + + // JWTPrivateKey — Ed25519 (base64: 32-байтовый seed или 64-байтовый ключ). Пусто — случайный ключ + // на время жизни процесса: после перезапуска всем придётся обновить access-токен (refresh остаётся). + JWTPrivateKey string `env:"JWT_PRIVATE_KEY"` + // JWTPreviousKeys — прежние ключи через запятую: токены, подписанные ими, ещё принимаются. + JWTPreviousKeys []string `env:"JWT_PREVIOUS_KEYS" envSeparator:","` + AccessTokenTTL time.Duration `env:"ACCESS_TOKEN_TTL" envDefault:"15m"` + // CookieSecure — флаг Secure у refresh-cookie. Отключать только для разработки по http не на localhost. + CookieSecure bool `env:"COOKIE_SECURE" envDefault:"true"` + + BootstrapAdminUsername string `env:"BOOTSTRAP_ADMIN_USERNAME" envDefault:"admin"` + BootstrapAdminPassword string `env:"BOOTSTRAP_ADMIN_PASSWORD"` } // StorageAgent — конфигурация внутреннего сервиса storage-agent. diff --git a/services/api/internal/platform/httpx/problem.go b/services/api/internal/platform/httpx/problem.go index bb5d624..cc96e4e 100644 --- a/services/api/internal/platform/httpx/problem.go +++ b/services/api/internal/platform/httpx/problem.go @@ -3,24 +3,49 @@ package httpx import ( "encoding/json" + "errors" "log/slog" "net/http" + "strconv" + + "pipu/api/internal/platform/apperr" ) // Problem — тело ответа application/problem+json. Поле Code переводится клиентом через i18n. type Problem struct { - Type string `json:"type"` - Status int `json:"status"` - Code string `json:"code"` - Detail string `json:"detail,omitempty"` - Params map[string]any `json:"params,omitempty"` + Type string `json:"type"` + Status int `json:"status"` + Code string `json:"code"` + Detail string `json:"detail,omitempty"` + Params map[string]any `json:"params,omitempty"` + Errors []apperr.FieldError `json:"errors,omitempty"` } // WriteProblem пишет ответ об ошибке. func WriteProblem(w http.ResponseWriter, status int, code, detail string) { + writeProblem(w, Problem{Type: "about:blank", Status: status, Code: code, Detail: detail}) +} + +func writeProblem(w http.ResponseWriter, p Problem) { w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(Problem{Type: "about:blank", Status: status, Code: code, Detail: detail}) + w.WriteHeader(p.Status) + _ = json.NewEncoder(w).Encode(p) +} + +// WriteError пишет доменную ошибку *apperr.Error; любую другую — как 500 (подробности в лог). +func WriteError(log *slog.Logger, w http.ResponseWriter, r *http.Request, err error) { + var ae *apperr.Error + if !errors.As(err, &ae) { + log.ErrorContext(r.Context(), "handler failed", "path", r.URL.Path, "err", err) + WriteProblem(w, http.StatusInternalServerError, "common.internal", "internal error") + return + } + if ae.RetryAfter > 0 { + w.Header().Set("Retry-After", strconv.Itoa(ae.RetryAfter)) + } + writeProblem(w, Problem{ + Type: "about:blank", Status: ae.Status, Code: ae.Code, Detail: ae.Detail, Params: ae.Params, Errors: ae.Fields, + }) } // NotFound — обработчик неизвестных путей. @@ -38,10 +63,7 @@ func RequestError(w http.ResponseWriter, _ *http.Request, err error) { WriteProblem(w, http.StatusBadRequest, "common.bad_request", err.Error()) } -// ResponseError — непредвиденная ошибка обработчика: детали только в лог. +// ResponseError — ошибка обработчика: доменные — как есть, прочие — 500 с деталями только в логе. func ResponseError(log *slog.Logger) func(http.ResponseWriter, *http.Request, error) { - return func(w http.ResponseWriter, r *http.Request, err error) { - log.ErrorContext(r.Context(), "handler failed", "path", r.URL.Path, "err", err) - WriteProblem(w, http.StatusInternalServerError, "common.internal", "internal error") - } + return func(w http.ResponseWriter, r *http.Request, err error) { WriteError(log, w, r, err) } } diff --git a/services/api/internal/server/router.go b/services/api/internal/server/router.go index dfe1447..19e4a60 100644 --- a/services/api/internal/server/router.go +++ b/services/api/internal/server/router.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "pipu/api/internal/api/gen" + "pipu/api/internal/auth" "pipu/api/internal/platform/httpx" "pipu/api/internal/system" ) @@ -17,12 +18,22 @@ import ( type Deps struct { Log *slog.Logger System *system.Handler + Auth *auth.Handler + // AuthService проверяет access-токены в middleware. nil — только для тестов служебных эндпоинтов. + AuthService *auth.Service } // handlers объединяет модули в единую реализацию gen.StrictServerInterface. -// По мере появления модулей (auth, catalog, ...) они встраиваются сюда. +// По мере появления модулей (catalog, ...) они встраиваются сюда. +// Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler. +type ( + systemHandler = system.Handler + authHandler = auth.Handler +) + type handlers struct { - *system.Handler + *systemHandler + *authHandler } var _ gen.StrictServerInterface = handlers{} @@ -36,10 +47,13 @@ func NewRouter(d Deps) http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID, httpx.RealIP, httpx.AccessLog(log), middleware.Recoverer) + if d.AuthService != nil { + r.Use(auth.Middleware(d.AuthService, log)) + } r.NotFound(httpx.NotFound) r.MethodNotAllowed(httpx.MethodNotAllowed) - strict := gen.NewStrictHandlerWithOptions(handlers{Handler: d.System}, nil, gen.StrictHTTPServerOptions{ + strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth}, nil, gen.StrictHTTPServerOptions{ RequestErrorHandlerFunc: httpx.RequestError, ResponseErrorHandlerFunc: httpx.ResponseError(log), }) diff --git a/services/api/internal/system/handler.go b/services/api/internal/system/handler.go index 303413f..5c8cc82 100644 --- a/services/api/internal/system/handler.go +++ b/services/api/internal/system/handler.go @@ -26,6 +26,8 @@ type Info struct { Version string SupportedLocales []string MinAndroidVersionCode int + // BootstrapRequired сообщает, что первый вход администратора не завершён (nil — false). + BootstrapRequired func(ctx context.Context) (bool, error) } // Handler реализует системную часть gen.StrictServerInterface. @@ -86,8 +88,16 @@ func (h *Handler) GetReadyz(ctx context.Context, _ gen.GetReadyzRequestObject) ( } // GetServerInfo отдаёт сведения о сервере для экрана подключения клиентов. -func (h *Handler) GetServerInfo(context.Context, gen.GetServerInfoRequestObject) (gen.GetServerInfoResponseObject, error) { +func (h *Handler) GetServerInfo(ctx context.Context, _ gen.GetServerInfoRequestObject) (gen.GetServerInfoResponseObject, error) { + var bootstrap bool + if h.info.BootstrapRequired != nil { + var err error + if bootstrap, err = h.info.BootstrapRequired(ctx); err != nil { + return nil, err + } + } return gen.GetServerInfo200JSONResponse{ + BootstrapRequired: bootstrap, Name: "pipu-music", Version: h.info.Version, ApiVersion: "v1", diff --git a/services/api/migrations/00002_users.sql b/services/api/migrations/00002_users.sql new file mode 100644 index 0000000..aa846dd --- /dev/null +++ b/services/api/migrations/00002_users.sql @@ -0,0 +1,55 @@ +-- +goose Up +CREATE TYPE user_role AS ENUM ('admin', 'moderator', 'user'); + +CREATE TABLE users ( + id uuid PRIMARY KEY, + username citext NOT NULL UNIQUE, + display_name text, + password_hash text NOT NULL, + role user_role NOT NULL DEFAULT 'user', + locale text NOT NULL DEFAULT 'ru', + ui_prefs jsonb NOT NULL DEFAULT '{}', + must_change_password boolean NOT NULL DEFAULT false, + must_change_credentials boolean NOT NULL DEFAULT false, + is_blocked boolean NOT NULL DEFAULT false, + token_version int NOT NULL DEFAULT 0, + failed_login_count int NOT NULL DEFAULT 0, + locked_until timestamptz, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + last_login_at timestamptz +); + +CREATE TABLE refresh_tokens ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + family_id uuid NOT NULL, + family_started_at timestamptz NOT NULL, + token_hash bytea NOT NULL UNIQUE, + device_name text, + client_type text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + used_at timestamptz, + revoked_at timestamptz +); +CREATE INDEX refresh_tokens_user_active_idx ON refresh_tokens (user_id) WHERE revoked_at IS NULL; +CREATE INDEX refresh_tokens_family_idx ON refresh_tokens (family_id); + +CREATE TABLE audit_log ( + id uuid PRIMARY KEY, + actor_id uuid REFERENCES users(id) ON DELETE SET NULL, + action text NOT NULL, + target_type text, + target_id uuid, + payload jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX audit_log_created_idx ON audit_log (created_at DESC, id DESC); + +-- +goose Down +DROP TABLE audit_log; +DROP TABLE refresh_tokens; +DROP TABLE users; +DROP TYPE user_role;