переделал токен
This commit is contained in:
39
docs/API.md
39
docs/API.md
@@ -23,7 +23,7 @@
|
||||
{
|
||||
"success": true,
|
||||
"message": "OK",
|
||||
"token": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"token": "eyJhbGciOiJIUzI1NiJ9...",
|
||||
"role": "ADMIN",
|
||||
"redirect": "/admin/",
|
||||
"departmentId": 1,
|
||||
@@ -31,6 +31,8 @@
|
||||
}
|
||||
```
|
||||
|
||||
Ответ также устанавливает `HttpOnly` cookie `magistr_refresh` для обновления access-токена.
|
||||
|
||||
**Ошибка (401):**
|
||||
```json
|
||||
{
|
||||
@@ -44,7 +46,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer <token>`
|
||||
> После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer <token>`.
|
||||
|
||||
Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`.
|
||||
|
||||
@@ -59,6 +61,37 @@ Redirect по ролям:
|
||||
| `TEACHER` | `/teacher/` |
|
||||
| `STUDENT` | `/student/` |
|
||||
|
||||
### `POST /api/auth/refresh`
|
||||
|
||||
Обновляет access JWT по refresh-cookie. Тело запроса не требуется.
|
||||
|
||||
**Успешный ответ (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "OK",
|
||||
"token": "eyJhbGciOiJIUzI1NiJ9...",
|
||||
"role": "ADMIN",
|
||||
"redirect": "/admin/",
|
||||
"departmentId": 1,
|
||||
"userId": 1
|
||||
}
|
||||
```
|
||||
|
||||
Refresh-токен ротируется при каждом успешном обновлении, а старый refresh-токен отзывается.
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
Отзывает текущий refresh-токен и очищает refresh-cookie.
|
||||
|
||||
**Успешный ответ (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Выход выполнен"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/auth/me`
|
||||
|
||||
Возвращает текущего пользователя по bearer-токену.
|
||||
@@ -151,7 +184,7 @@ Redirect по ролям:
|
||||
|
||||
## Права ролей на API
|
||||
|
||||
Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, проходят через bearer-токен и `@RequireRoles`.
|
||||
Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, `POST /api/auth/refresh` и `POST /api/auth/logout`, проходят через bearer access JWT и `@RequireRoles`.
|
||||
|
||||
Важные ограничения:
|
||||
|
||||
|
||||
@@ -131,16 +131,15 @@ sequenceDiagram
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Система использует простую модель аутентификации без JWT и без полноценного Spring Security:
|
||||
Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:
|
||||
|
||||
1. Клиент отправляет `POST /api/auth/login` с `username` и `password`
|
||||
2. Backend проверяет пароль через `BCryptPasswordEncoder`
|
||||
3. При успехе возвращается:
|
||||
- UUID-токен (для заголовка `Authorization: Bearer`)
|
||||
- Роль пользователя (`ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`)
|
||||
- Redirect URL (`/admin/`, `/admin/#schedule-view`, `/admin/#department-workspace`, `/teacher/`, `/student/`)
|
||||
4. Токен хранится в `localStorage` на клиенте
|
||||
1. Клиент отправляет `POST /api/auth/login` с `username` и `password`.
|
||||
2. Backend проверяет пароль через `BCryptPasswordEncoder`.
|
||||
3. При успехе возвращается access JWT для заголовка `Authorization: Bearer <token>` и устанавливается `HttpOnly` refresh-cookie.
|
||||
4. Access JWT хранится в `localStorage`; refresh-токен хранится только в cookie, а в БД сохраняется SHA-256 хэш.
|
||||
5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается.
|
||||
6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie.
|
||||
|
||||
Токены хранятся в `AuthSessionService` в памяти процесса backend. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, кроме `POST /api/auth/login`, и применяет аннотацию `@RequireRoles` на контроллерах и методах. Это означает, что UI-роль в `localStorage` больше не является единственной защитой: backend возвращает `401`, если токена нет, и `403`, если роли недостаточно.
|
||||
Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, `exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение `tenant` с `TenantContext`, затем применяет `@RequireRoles`. Это означает, что UI-роль в `localStorage` остаётся только удобством: backend возвращает `401`, если токен отсутствует/некорректен, и `403`, если роли недостаточно.
|
||||
|
||||
`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution.
|
||||
`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.
|
||||
|
||||
@@ -48,6 +48,17 @@ erDiagram
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMP updated_at
|
||||
}
|
||||
|
||||
auth_refresh_tokens {
|
||||
BIGSERIAL id PK
|
||||
BIGINT user_id FK
|
||||
VARCHAR tenant
|
||||
VARCHAR token_hash UK
|
||||
TIMESTAMP issued_at
|
||||
TIMESTAMP expires_at
|
||||
TIMESTAMP revoked_at
|
||||
VARCHAR rotated_to_token_hash
|
||||
}
|
||||
|
||||
education_forms {
|
||||
BIGSERIAL id PK
|
||||
@@ -292,6 +303,7 @@ erDiagram
|
||||
student_groups ||--o{ schedule_rule_groups : "group_id"
|
||||
student_groups ||--o{ student_group_calendar_assignments : "group_id"
|
||||
users ||--o{ teacher_subjects : "user_id"
|
||||
users ||--o{ auth_refresh_tokens : "user_id"
|
||||
users ||--o{ teacher_department_assignments : "teacher_id"
|
||||
departments ||--o{ teacher_department_assignments : "department_id"
|
||||
users ||--o{ teacher_lesson_types : "user_id"
|
||||
@@ -379,6 +391,22 @@ erDiagram
|
||||
|
||||
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`.
|
||||
|
||||
#### `auth_refresh_tokens` — Refresh-сессии JWT
|
||||
| Колонка | Тип | Описание |
|
||||
|---------|-----|----------|
|
||||
| `id` | BIGSERIAL PK | ID refresh-сессии |
|
||||
| `user_id` | BIGINT FK → users (CASCADE) | Пользователь |
|
||||
| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен |
|
||||
| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена |
|
||||
| `issued_at` | TIMESTAMP | Дата выдачи |
|
||||
| `expires_at` | TIMESTAMP | Дата истечения |
|
||||
| `revoked_at` | TIMESTAMP | Дата отзыва, `NULL` для активной сессии |
|
||||
| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации |
|
||||
| `user_agent` | VARCHAR(512) | User-Agent клиента |
|
||||
| `ip_address` | VARCHAR(64) | IP-адрес клиента |
|
||||
|
||||
Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый refresh-токен отзывается, а клиент получает новый refresh-cookie.
|
||||
|
||||
### Учебный процесс
|
||||
|
||||
#### `education_forms` — Формы обучения
|
||||
@@ -674,17 +702,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
|
||||
|
||||
1. Все миграции находятся в `backend/src/main/resources/db/migration/`
|
||||
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
|
||||
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway
|
||||
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
|
||||
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
|
||||
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
|
||||
|
||||
> Текущая ветка календарного учебного графика является осознанным исключением: `V1__init.sql` переписан как новая базовая схема, а применение предполагает полный сброс БД без переноса старых данных.
|
||||
> Текущая JWT-правка является осознанным исключением по прямой просьбе пользователя: `V1__init.sql` обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
|
||||
|
||||
### Текущие миграции
|
||||
|
||||
| Файл | Описание |
|
||||
|------|----------|
|
||||
| `V1__init.sql` | Инициализация: справочники, роли, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||
|
||||
### Накатывание на существующих тенантов
|
||||
|
||||
|
||||
@@ -222,11 +222,13 @@ public class AbsenceController {
|
||||
|
||||
### Правила
|
||||
|
||||
1. **Никогда** не изменяйте уже закоммиченные файлы миграций
|
||||
1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя
|
||||
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
|
||||
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
|
||||
4. После добавления — перезапустите backend для применения
|
||||
|
||||
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
|
||||
|
||||
### Применение
|
||||
|
||||
```bash
|
||||
|
||||
@@ -171,19 +171,24 @@ frontend/
|
||||
|
||||
## API-клиент (`api.js`)
|
||||
|
||||
Все HTTP-запросы проходят через обёртку `apiFetch()`:
|
||||
Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом:
|
||||
|
||||
```javascript
|
||||
export async function apiFetch(endpoint, method = 'GET', body = null) {
|
||||
export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: body ? JSON.stringify(body) : null
|
||||
headers: getHeaders(body ? 'application/json' : null),
|
||||
credentials: 'same-origin',
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
if (response.status === 401 && retryOnUnauthorized) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) return apiFetch(endpoint, method, body, false);
|
||||
clearAuthState();
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||
}
|
||||
@@ -200,7 +205,7 @@ export const api = {
|
||||
};
|
||||
```
|
||||
|
||||
Токен берётся из `localStorage.getItem('token')`.
|
||||
При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа.
|
||||
|
||||
---
|
||||
|
||||
@@ -211,11 +216,12 @@ export const api = {
|
||||
1. Пользователь вводит логин/пароль
|
||||
2. `script.js` отправляет `POST /api/auth/login`
|
||||
3. При успехе сохраняет в `localStorage`:
|
||||
- `token` — UUID-токен
|
||||
- `token` — access JWT
|
||||
- `role` — роль пользователя
|
||||
- `departmentId` — кафедра пользователя
|
||||
- `userId` — ID пользователя для личного расписания преподавателя
|
||||
4. Перенаправляет на соответствующий интерфейс:
|
||||
4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript
|
||||
5. Перенаправляет на соответствующий интерфейс:
|
||||
- `ADMIN` → `/admin/`
|
||||
- `EDUCATION_OFFICE` → `/admin/#schedule-view`
|
||||
- `DEPARTMENT` → `/admin/#department-workspace`
|
||||
@@ -230,13 +236,13 @@ export const api = {
|
||||
```javascript
|
||||
export function isAuthenticatedAsAdmin() {
|
||||
const role = localStorage.getItem('role');
|
||||
return token && role === 'ADMIN';
|
||||
return getToken() && role === 'ADMIN';
|
||||
}
|
||||
```
|
||||
|
||||
### Выход
|
||||
|
||||
Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`.
|
||||
Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,8 +26,13 @@ docker network create proxy
|
||||
```env
|
||||
POSTGRES_USER=myuser
|
||||
POSTGRES_PASSWORD=supersecretpassword
|
||||
JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes
|
||||
JWT_ACCESS_TOKEN_TTL=15m
|
||||
JWT_REFRESH_TOKEN_TTL=7d
|
||||
```
|
||||
|
||||
`JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене он задаётся через Kubernetes Secret `app-secret`.
|
||||
|
||||
### Dockerfile (Backend)
|
||||
|
||||
Backend собирается через multi-stage сборку Maven:
|
||||
@@ -58,6 +63,16 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
|
||||
| `frontend` | Deployment | Apache httpd |
|
||||
| `tenants-config` | ConfigMap | JSON-список тенантов |
|
||||
|
||||
### JWT настройки
|
||||
|
||||
`app-config` задаёт TTL access/refresh-токенов и признак Secure-cookie:
|
||||
|
||||
- `JWT_ACCESS_TOKEN_TTL=15m`
|
||||
- `JWT_REFRESH_TOKEN_TTL=7d`
|
||||
- `JWT_REFRESH_COOKIE_SECURE=true`
|
||||
|
||||
`app-secret` задаёт `JWT_SECRET`. Его нельзя логировать или хранить в публичных артефактах как реальный продакшн-секрет.
|
||||
|
||||
### ConfigMap для тенантов
|
||||
|
||||
ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`.
|
||||
|
||||
Reference in New Issue
Block a user