# Backend: JWT и Refresh Tokens — Реализация и Best Practices **Контекст**: В `backend/internal/services/auth.go` реализована JWT-аутентификация с access (15 мин) и refresh (30 дней) токенами. Реализация включает серверное хранение refresh токенов, ротацию и детект повторного использования. **Обновлено после code review 2026-06 — код уже исправлен, заметка отражает текущее состояние.** ## Суть Система аутентификации использует два типа токенов: 1. **Access token** — JWT, 15 мин, передаётся в `Authorization: Bearer`, содержит `user_id` и `role` 2. **Refresh token** — криптостойкая случайная строка (32 байта, base64), 30 дней, хранится в HttpOnly cookie ## Архитектура ```mermaid sequenceDiagram participant C as Client participant A as Go API participant DB as PostgreSQL C->>A: POST /auth/login { email, password } A->>DB: SELECT user WHERE email A->>A: bcrypt.CompareHashAndPassword A->>DB: INSERT refresh_token (SHA-256 hash) A-->>C: 200 { access_token, user } Note over C: Set-Cookie: refresh_token (HttpOnly, Secure, SameSite) C->>A: GET /auth/me (Authorization: Bearer access_token) A->>A: JWT validation (signature + expiry) A-->>C: 200 { user_id, role } C->>A: POST /auth/refresh (Cookie: refresh_token) A->>DB: SELECT refresh_token WHERE token_hash = SHA-256(cookie) A->>DB: DELETE old token (if exists — rotation) A->>DB: INSERT new refresh_token A-->>C: 200 { new_access_token } Note over C: Set-Cookie: new_refresh_token alt Token Reuse Detected C->>A: POST /auth/refresh (stale refresh_token) A->>DB: SELECT — not found (already rotated) A->>DB: SELECT revoked — found! A->>DB: DELETE ALL tokens for user_id A-->>C: 401 token reused, session revoked end ``` ## Реализация ### Генерация токенов (`services/auth.go:152-182`) ```go func (s *AuthService) generateTokens(ctx context.Context, user *models.User) (*AuthResult, string, error) { accessToken, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, TokenClaims{ UserID: user.ID, Role: user.Role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), }, }).SignedString(s.jwtSecret) refreshToken, _ := generateSecureToken(32) // crypto/rand, base64 // Хеш SHA-256 → сохраняем в БД s.refreshTokenRepo.Create(ctx, user.ID, refreshToken, expiresAt) return &AuthResult{User: user, AccessToken: accessToken}, refreshToken, nil } ``` ### Ротация с защитой от race condition (`services/auth.go:198-249`) ```go func (s *AuthService) RefreshSession(ctx context.Context, plainRefreshToken string) (*AuthResult, string, error) { // 1. Ищем валидный токен в БД storedToken, _ := s.refreshTokenRepo.GetValid(ctx, plainRefreshToken) if storedToken == nil { // 2. Не найден → проверяем, не был ли он отозван revokedToken, _ := s.refreshTokenRepo.GetRevoked(ctx, plainRefreshToken) if revokedToken != nil { // 3. Token reuse! Отзываем ВСЕ сессии пользователя s.refreshTokenRepo.RevokeAllForUser(ctx, revokedToken.UserID) return nil, "", ErrTokenReused } return nil, "", ErrInvalidToken } // 4. Атомарная ротация: удаляем старый, создаём новый deleted, _ := s.refreshTokenRepo.DeleteIfExists(ctx, storedToken.TokenHash) if !deleted { return nil, "", ErrTokenReused // concurrent rotation detected } return s.generateTokens(ctx, user) } ``` ## Почему это правильно 1. **Refresh token НЕ JWT** — это random string, хранящаяся в БД. Нельзя украсть подпись. 2. **SHA-256 хеш в БД** — даже при утечке БД, refresh token не скомпрометирован. 3. **Ротация при каждом refresh** — старый токен становится недействительным. Replay-атака невозможна. 4. **`DeleteIfExists` проверяет `rows_affected`** — гарантирует, что только одна горутина выиграет race. 5. **Token reuse → RevokeAllForUser** — при обнаружении повторного использования отзываются все сессии пользователя. ## Использование cookie ```go // handlers/auth.go:153-168 http.Cookie{ Name: "refresh_token", HttpOnly: true, Secure: h.isProd, SameSite: http.SameSiteStrictMode, // CSRF защита Path: "/", MaxAge: int((30 * 24 * time.Hour).Seconds()), } ``` - `HttpOnly: true` — защита от XSS (JavaScript не прочитает cookie) - `Secure: true` — только по HTTPS (в production) - `SameSite: Strict` — защита от CSRF - `Path: "/"` — доступен на всех путях (включая `/auth/refresh`) ## Middleware аутентификации ```go // middleware/auth.go:23-48 func AuthMiddleware(authSvc *services.AuthService) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") // Парсинг "Bearer " claims, err := authSvc.ValidateAccessToken(token) if err != nil { writeAuthError(w, "invalid or expired token") return } // Встраиваем user_id и role в контекст ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID) ctx = context.WithValue(ctx, UserRoleKey, claims.Role) next.ServeHTTP(w, r.WithContext(ctx)) }) } } ``` ## Ролевая модель | Роль | Разрешения | |---|---| | `superadmin` | Полный доступ | | `moderator` | Модерация мест, управление пользователями | | `landlord` | Создание/редактирование своих студий | | `executor` | Создание услуг | | `customer` | Создание мест | `RoleMiddleware(roles ...string)` проверяет роль из контекста. ## Альтернативы (для справки) | Подход | Плюсы | Минусы | |---|---|---| | **Текущий: БД + ротация** | Полный контроль, отзыв, безопасность | +1 round-trip к БД | | **JWT blacklist в Redis** | Быстро, не трогает БД | Нужно хранить до expiry | | **Refresh token как JWT (без БД)** | Просто, 0 запросов к БД | **Нельзя отозвать** — утечка = полный доступ | ## Связанные заметки - [[decision-jwt-refresh-storage]] — Decision record: почему БД - [[decision-validation-error-production]] — Безопасность ошибок - [[architecture-overview]] — Общий обзор - [[atomic-csrf-protection]] — SameSite cookie - [[atomic-refresh-token-race-condition]] — Race-free ротация ## Источник Code review PhotoPlaces 2026-06. Актуализация после внедрения refresh token rotation в `services/auth.go`. ## Теги #backend #security #jwt #auth #best-practice