20
0
0
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
Назад

Эволюция клиента для Ollama: Великое отчуждение, или Как я перестал бояться микросервисов и обрел JWT-дзен

Время чтения 49 минут
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
20
0
0
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники

«Сервис должен быть stateless, иначе твоя жизнь превратится в state-кошмар».
Будда, если бы он шарил в микросервисах

В прошлых сериях мы прошли путь от убогого стриминга до мультимодального чата с FSD-архитектурой на фронте. Код лежал в одной папке backend, сессия хранилась в localStorage, и пользователь был привязан к браузеру как улитка к раковине.

Жизнь была прекрасна. Но было три НО, которые не давали мне спать по ночам:

  • Проблема №1. Монада. Бэкенд разбух до состояния, когда авторизация, бизнес-логика чата и управление моделями переплелись в тугой гордиев узел. Чтобы обновить логику JWT, я должен был пересобирать весь чат-сервис. Это как менять свечу зажигания, разбирая половину двигателя.
  • Проблема №2. Сессия-сирота. parentSessionId жил в localStorage браузера. Это значило, что сессия умирала вместе с вкладкой. Открыл в Chrome — один диалог. Открыл в Firefox — другой. Очистил куки — потерял историю разговоров навсегда.
  • Проблема №3. Один пользователь — одна беседа. Ты не мог параллельно обсуждать архитектуру с DeepSeek и писать код с Qwen. Не мог вернуться ко вчерашнему разговору о микросервисах, потому что вчерашнего разговора не было. Была только текущая сессия. Сиюминутная, эфемерная, как молитва в исполнении атеиста.

Я решил, что пора выходить из монады, отвязать сессию от браузера и дать пользователю суперспособность — вести сотню параллельных диалогов, как заправский менеджер проектов.

Эволюция клиента для Ollama: Великое отчуждение, или Как я перестал бояться микросервисов и обрел JWT-дзен

Великий Maven-переезд: рождение мультимодульности

Первое, что изменилось, — корневой pom.xml. Раньше там был один модуль.

Было:


xml
<modules>
<module>backend</module>
</modules>

 

Стало:


xml
<modules>
<module>backend</module>
<module>gateway</module>   <!-- Новый жилец -->
<module>common</module> <!-- Общая курилка -->
<module>auth</module>    <!-- Служба пропусков -->
</modules>

 

Важный нюанс: backend остался ровно таким же, каким был. Никто не резал его на куски, мы просто поставили рядом два новых сервиса и один общий модуль.

Common: наша общая курилка

Появилась папка common. Раньше я копировал классы вроде AuthRequest из backend в auth ручками. Однажды я перепутал поля, и бэкенд перестал понимать, что за JSON ему шлют.

common — это библиотека DTO-шек, которые нужны всем. Сюда переехали:

  • AuthRequest, AuthResponse
  • RegisterRequest, RefreshTokenRequest
  • TokenValidationRequest, TokenValidationResponse

Теперь backend и auth просто тянут зависимость:


xml
<dependency>
<groupId>ru.akademit.ai</groupId>
<artifactId>ollama-common</artifactId>
</dependency>

 

И живут в мире и согласии, сверяя классы.

Auth: рождение Штази (и правильных сессий)

Сервис auth (порт 8082) — это маленький, но гордый модуль. Он знает всё о пользователях: регистрация, логин, JWT-токены, хеширование паролей.

Но самое главное — он знает о сессиях.

Раньше parentSessionId генерировался на фронте:


typescript
// Было: сессия рождается в браузере
let sid = localStorage.getItem(STORAGE_KEYS.PARENT_SESSION_ID);
if (!sid) {
sid = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
    localStorage.setItem(STORAGE_KEYS.PARENT_SESSION_ID, sid);
}

 

Теперь сессия рождается на сервере и привязывается к пользователю.

Структура JWT:


json
{
  "sub": "alexey",
  "sessionId": "uuid-12345-67890",  // ← ключ от конкретной комнаты
  "type": "access",
  "exp": 1734567890
}

 

Ключевое изменение: sessionId внутри токена — это идентификатор конкретной беседы, а не общий для всех. Один пользователь alexey может иметь 10 токенов с 10 разными sessionId. Каждый токен — ключ от отдельной комнаты переговоров.

Я храню не сам Refresh Token, а его SHA-256 хеш. Если даже базу украдут, токенами не поторгуешь — только бесполезными хешами. Это как хранить в сейфе не золото, а фотографию золота.

Gateway: консьерж, который знает всех

Самый элегантный модуль — gateway (порт 8081). Это даже не сервис, а умный шлюз. Фронтенд стучится только в него, а gateway решает, кому звонить.

Его маршруты (из application.yml):


yaml
spring:
  cloud:
gateway:
   routes:
     - id: auth_route
       uri: ${AUTH_URL:http://localhost:8082}
       predicates:
            - Path=/api/auth/**       # Все /api/auth — в Auth
     - id: api_route
       uri: ${BACKEND_URL:http://localhost:8080}
       predicates:
            - Path=/api/**            # Остальные /api — в Backend
     - id: websocket_route
       uri: ${BACKEND_URL:http://localhost:8080}
       predicates:
            - Path=/ws/**             # Вебсокеты — туда же, в Backend

 

Почему это хорошо:

  • Единая точка входа. Фронтенд больше не знает, что auth живет на 8082, а backend — на 8080. Он знает только gateway на 8081. Упрощает жизнь донельзя.
  • CORS наконец-то побежден. Раньше я мучился с WebConfig.java в бэкенде. Теперь CORS настраивается один раз в gateway — для всех сервисов.
  • Вебсокеты не сломались. Я боялся, что gateway «пожует» заголовки Upgrade. Оказалось, если не писать лишних фильтров, он просто пробрасывает трафик.

Как бэкенд проверяет пропуска: публичный ключ и валидация без секретов

Итак, токены генерируются в auth. Gateway пробрасывает их дальше. Но возникает закономерный вопрос: как бэкенд узнает, что токен настоящий, если он не знает приватного ключа? Ответ прост: публичный ключ.

В бэкенде появился новый класс TokenValidationService и конфиг JwtValidationConfig:


java
// backend/src/main/java/ru/akademit/ai/ollama/config/JwtValidationConfig.java
@Configuration
@Getter
public class JwtValidationConfig {
@Value("${jwt.public-key}")
private String publicKeyBase64;  // Тот же ключ, что и в auth, только публичная часть
}

 

Схема проверки сводится к трем шагам:

  1. Auth генерирует токены, подписывая их приватным ключом.
  2. Backend (и любой другой сервис) получает публичный ключ. Этим ключом можно только проверить подпись, но не создать новый токен.
  3. Если токен поддельный или был изменен, валидация упадет с ошибкой.

java
@Service
@Slf4j
@RequiredArgsConstructor
public class TokenValidationService {
private final JwtValidationConfig jwtConfig;
private PublicKey publicKey;
 
@PostConstruct
public void init() {
     byte[] publicKeyBytes = Base64.getDecoder().decode(jwtConfig.getPublicKeyBase64());
     X509EncodedKeySpec publicSpec = new X509EncodedKeySpec(publicKeyBytes);
     KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        publicKey = keyFactory.generatePublic(publicSpec);
}
 
public boolean isTokenValid(String token) {
     Claims claims = validateToken(token);
     if (claims == null) return false;
    
     // Проверяем срок годности
     if (claims.getExpiration().before(Date.from(Instant.now()))) return false;
    
     // Убеждаемся, что это access token, а не refresh
     String tokenType = claims.get("type", String.class);
     return "access".equals(tokenType);
}
 
private Claims validateToken(String token) {
     return Jwts.parser()
                .verifyWith(publicKey)  // Верификация публичным ключом!
             .build()
                .parseSignedClaims(token)
                .getPayload();
}
}

 

Магия асимметричного шифрования в действии.

Ключи лежат в .env файлах (или в docker-compose.yml):


yaml
# docker-compose.yml
auth:
  environment:
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}   # Только в auth
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY} # Публичный тоже нужен для генерации
 
backend:
  environment:
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY} # Только публичный!

 

Публичный ключ можно даже зашить в образ и не прятать. Приватный — строго в секретах.

В WebSocketConfig.java при подключении клиента токен тоже проверяется:


java
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
    registration.interceptors(new ChannelInterceptor() {
     @Override
     public Message<?> preSend(Message<?> message, MessageChannel channel) {
            StompHeaderAccessor accessor = ...;
            if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                String token = extractToken(accessor);
                if (tokenValidationService.isTokenValid(token)) {
                 String username = tokenValidationService.extractUsername(token);
                 String sessionId = tokenValidationService.extractSessionId(token);
                
                 // Кладем в контекст WebSocket-сессии
                 accessor.setUser(() -> username);
                 accessor.setSessionAttributes(Map.of("sessionId", sessionId));
                } else {
                 return null; // Отклоняем подключение
                }
            }
         return message;
     }
});
}

 

Без валидного токена вебсокет-соединение даже не установится. Порядок.

В итоге получаем безопасность и слабую связность. Даже если бэкенд взломают, токены подделывать не научатся — приватного ключа там нет. Auth может быть недоступен, но бэкенд продолжит проверять уже выданные токены. Ему не нужно каждый раз бегать в auth за подтверждением.

3 новые сущности в MongoDB

Старая коллекция chat_sessions была бесхозной. Я добавил ей хозяина.

Миграция V3 (V3__Add_auth_and_metadata.js):


javascript
// Добавляем поле username в каждую сессию
db.chat_sessions.updateMany(
{},
{ $set: { username: "anonymous" } }
);
 
// Создаем уникальный индекс: один пользователь — одна сессия с одним parent_session_id
db.chat_sessions.createIndex(
{ "username": 1, "parent_session_id": 1 },
{ unique: true }
);

 

Кроме того, появилась коллекция users:


javascript
{
"_id": ObjectId("..."),
"username": "alexey",
"email": "alexey@example.com",
"passwordHash": "$2a$10$...",
"sessions": [
     {
            "sessionId": "uuid-12345",
            "refreshTokenHash": "sha256...",
            "createdAt": ISODate("2024-12-15T10:00:00Z"),
            "expiresAt": ISODate("2024-12-16T10:00:00Z"),
            "userAgent": "Chrome/120.0",
            "ipAddress": "192.168.1.1"
     },
     {
            "sessionId": "uuid-67890",
            "refreshTokenHash": "sha256...",
         // Еще одна беседа, параллельная
     }
]
}

 

Один пользователь — много сессий, и каждая — это отдельный диалог со своим parent_session_id в chat_sessions.

Как фронтенд теперь управляет сессиями

Вход в систему (AuthPage.tsx):


typescript
const handleLogin = async (e: React.FormEvent) => {
const response = await fetch(`${API_BASE_URL}/api/auth/login`, {
        method: 'POST',
        body: JSON.stringify({ username, password })
});
const data = await response.json();
    localStorage.setItem('accessToken', data.accessToken);
    localStorage.setItem('refreshToken', data.refreshToken);
    localStorage.setItem('username', data.username);
// Ключевое изменение: parentSessionId больше не генерируется на фронте!
// Он придет в ответе от сервера при создании первой сессии
// Или будет лежать в localStorage от предыдущей авторизации
onAuthSuccess();
};

 

А где же создание новых сессий?

Честно говоря, в текущей реализации я не добавил интерфейс для множественных сессий. Это архитектурный задел на будущее. В коде есть:

  • модель данных в MongoDB (User.sessions), которая поддерживает множество сессий;
  • JWT-токен, который хранит sessionId внутри себя;
  • эндпоинт для создания новой сессии (POST /api/chat/session), но он пока не прикручен к фронтенду.

Что есть сейчас:

  • пользователь логинится и получает токен с sessionId;
  • этот sessionId используется для всех последующих запросов;
  • все сообщения сохраняются в ChatHistory с этим parent_session_id и привязкой к username;
  • если пользователь захочет начать новый диалог, он пока что просто очистит историю (кнопка Clear history).

Что можно будет сделать завтра:

  • добавить выпадающий список сессий в шапку;
  • реализовать переключение между сессиями без перезагрузки страницы;
  • дать пользователю возможность называть сессии («Обсуждение API», «Код для проекта», «Тесты промптов»).

Но это уже задача на следующий спринт. Пока что мы заложили фундамент.

Чистота, порядок, разобщенность

Эта серия не столько про авторизацию, сколько про переход от стадии «лишь бы работало» к «работает и не болит голова у пользователя».

Теперь мой проект — это не просто клиент для Ollama, а микросервисная архитектура в миниатюре, собранная на коленке, но работающая как швейцарские часы. Ну, может, как китайские, но зато без перебоев.

  1. Отчуждение ответственности. Auth знает о юзерах и сессиях, Backend знает об AI, Gateway знает о маршрутах. Никто не лезет в чужие дела.
  2. Масштабируемость. Если Auth ляжет, чат продолжит работать (для тех, кто уже залогинен). Если чату потребуется три реплики — пожалуйста.
  3. Пользователь — личность. Со своими сессиями, историей и контекстом. 

Код на GitVerse, ветка auth. Заходите, регистрируйтесь. Заложили фундамент под возможность ведения большого количества параллельных диалогов, надеюсь, это не сведет нас с ума.

Комментарии0
Тоже интересно
Комментировать
Поделиться
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники