backend-auth-security.md 7.8 KB

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

Архитектура

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)

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)

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

// 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 аутентификации

// 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 <token>"
            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