JWT Token

Что такое JWT?

JWT (JSON Web Token) — это компактный, URL-безопасный способ представления заявлений (claims) между двумя сторонами. Токен состоит из трех частей, разделенных точками: Header.Payload.Signature

Основные преимущества:

  • Stateless — не требует хранения сессий на сервере
  • Компактность — можно передавать через URL, POST параметры или HTTP заголовки
  • Самодостаточность — содержит всю необходимую информацию о пользователе
  • Безопасность — поддерживает цифровую подпись и шифрование

Структура JWT

Header (Заголовок)

Содержит метаданные о токене - тип токена и алгоритм подписи:

{
  "alg": "HS256",
  "typ": "JWT"
}

Основные алгоритмы:

  • HS256 (HMAC SHA-256) — симметричное шифрование, один секретный ключ
  • RS256 (RSA SHA-256) — асимметричное шифрование, пара ключей (приватный/публичный)
  • ES256 (ECDSA SHA-256) — эллиптические кривые, более быстрый чем RSA

Payload (Полезная нагрузка)

Содержит claims (заявления) — информацию о пользователе и дополнительные данные:

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622,
  "roles": ["USER", "ADMIN"]
}

Стандартные claims:

  • iss (issuer) — кто выдал токен
  • sub (subject) — для кого выдан токен (обычно ID пользователя)
  • aud (audience) — для какой аудитории предназначен
  • exp (expiration time) — время истечения токена
  • iat (issued at) — время создания токена
  • nbf (not before) — время, до которого токен не действителен

Signature (Подпись)

Создается путем подписи закодированного заголовка и payload с помощью секретного ключа:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

Реализация в Spring Boot

Зависимости Maven

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
</dependency>

JJWT — наиболее популярная библиотека для работы с JWT в Java. Поддерживает все основные алгоритмы подписи и имеет удобный fluent API.

Конфигурация

@Component
public class JwtUtil {
    
    private final String SECRET_KEY = "mySecretKey";
    private final int EXPIRATION_TIME = 86400000; // 24 часа
    
    private Key getSigningKey() {
        byte[] keyBytes = SECRET_KEY.getBytes();
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Важно: В продакшене используйте более сложный ключ (минимум 32 символа для HS256) и храните его в переменных окружения или конфигурационных файлах.

Генерация токена

public String generateToken(String username, List<String> roles) {
    Date now = new Date();
    Date expiration = new Date(now.getTime() + EXPIRATION_TIME);
    
    return Jwts.builder()
        .setSubject(username)
        .setIssuedAt(now)
        .setExpiration(expiration)
        .claim("roles", roles)
        .signWith(getSigningKey(), SignatureAlgorithm.HS256)
        .compact();
}

Метод compact() объединяет все части токена в итоговую строку Header.Payload.Signature.

Валидация и извлечение данных

public Claims extractClaims(String token) {
    return Jwts.parserBuilder()
        .setSigningKey(getSigningKey())
        .build()
        .parseClaimsJws(token)
        .getBody();
}

public String extractUsername(String token) {
    return extractClaims(token).getSubject();
}

public boolean isTokenExpired(String token) {
    return extractClaims(token).getExpiration().before(new Date());
}

public boolean validateToken(String token) {
    try {
        Jwts.parserBuilder()
            .setSigningKey(getSigningKey())
            .build()
            .parseClaimsJws(token);
        return true;
    } catch (JwtException | IllegalArgumentException e) {
        return false;
    }
}

parseClaimsJws() — парсит и проверяет подпись токена. Если подпись не совпадает или токен поврежден, выбрасывается исключение.

Интеграция с Spring Security

JWT Authentication Filter

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Autowired
    private JwtUtil jwtUtil;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain filterChain) throws ServletException, IOException {
        
        String authHeader = request.getHeader("Authorization");
        String token = null;
        String username = null;
        
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            token = authHeader.substring(7);
            username = jwtUtil.extractUsername(token);
        }
        
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            if (jwtUtil.validateToken(token)) {
                UsernamePasswordAuthenticationToken authToken = 
                    new UsernamePasswordAuthenticationToken(username, null, getAuthorities(token));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        
        filterChain.doFilter(request, response);
    }
    
    private Collection<? extends GrantedAuthority> getAuthorities(String token) {
        Claims claims = jwtUtil.extractClaims(token);
        List<String> roles = (List<String>) claims.get("roles");
        return roles.stream()
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());
    }
}

OncePerRequestFilter — гарантирует, что фильтр выполняется только один раз для каждого запроса. SecurityContextHolder — хранит информацию о текущем аутентифицированном пользователе в контексте потока.

Security Configuration

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }
}

SessionCreationPolicy.STATELESS — отключает создание HTTP сессий, поскольку JWT токены содержат всю необходимую информацию.

Refresh Token Pattern

Реализация Refresh Token

@Service
public class AuthService {
    
    private final JwtUtil jwtUtil;
    private final RefreshTokenRepository refreshTokenRepository;
    
    public AuthResponse authenticate(LoginRequest request) {
        // Проверка учетных данных
        
        String accessToken = jwtUtil.generateToken(username, roles);
        String refreshToken = generateRefreshToken(username);
        
        return AuthResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .build();
    }
    
    private String generateRefreshToken(String username) {
        String token = UUID.randomUUID().toString();
        RefreshToken refreshToken = RefreshToken.builder()
            .token(token)
            .username(username)
            .expiryDate(LocalDateTime.now().plusDays(7))
            .build();
        
        refreshTokenRepository.save(refreshToken);
        return token;
    }
    
    public AuthResponse refreshToken(String refreshToken) {
        RefreshToken storedToken = refreshTokenRepository.findByToken(refreshToken)
            .orElseThrow(() -> new TokenNotFoundException("Refresh token not found"));
        
        if (storedToken.getExpiryDate().isBefore(LocalDateTime.now())) {
            refreshTokenRepository.delete(storedToken);
            throw new TokenExpiredException("Refresh token expired");
        }
        
        String newAccessToken = jwtUtil.generateToken(storedToken.getUsername(), getUserRoles(storedToken.getUsername()));
        return AuthResponse.builder()
            .accessToken(newAccessToken)
            .refreshToken(refreshToken)
            .build();
    }
}

Refresh Token — долгоживущий токен, который используется для получения нового access токена без повторной аутентификации. Хранится в базе данных и может быть отозван.

Лучшие практики безопасности

1. Время жизни токенов

// Access Token - короткий срок (15-60 минут)
private final int ACCESS_TOKEN_EXPIRATION = 900000; // 15 минут

// Refresh Token - длительный срок (7-30 дней)
private final int REFRESH_TOKEN_EXPIRATION = 604800000; // 7 дней

2. Безопасное хранение ключей

@Value("${app.jwt.secret}")
private String jwtSecret;

@Value("${app.jwt.expiration}")
private int jwtExpiration;

3. Валидация дополнительных claims

public boolean validateToken(String token, String expectedAudience) {
    try {
        Claims claims = extractClaims(token);
        return !isTokenExpired(token) && 
               expectedAudience.equals(claims.getAudience());
    } catch (Exception e) {
        return false;
    }
}

4. Blacklist для отозванных токенов

@Service
public class TokenBlacklistService {
    
    private final RedisTemplate<String, String> redisTemplate;
    
    public void blacklistToken(String token) {
        Claims claims = jwtUtil.extractClaims(token);
        long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
        
        if (ttl > 0) {
            redisTemplate.opsForValue().set("blacklist:" + token, "true", ttl, TimeUnit.MILLISECONDS);
        }
    }
    
    public boolean isBlacklisted(String token) {
        return redisTemplate.hasKey("blacklist:" + token);
    }
}

Redis используется для хранения blacklist токенов, поскольку поддерживает TTL (time to live) и обеспечивает быстрый доступ.

Обработка ошибок

Custom Exception Handler

@RestControllerAdvice
public class JwtExceptionHandler {
    
    @ExceptionHandler(ExpiredJwtException.class)
    public ResponseEntity<ErrorResponse> handleExpiredJwtException(ExpiredJwtException e) {
        ErrorResponse error = ErrorResponse.builder()
            .message("Token expired")
            .errorCode("JWT_EXPIRED")
            .timestamp(LocalDateTime.now())
            .build();
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
    }
    
    @ExceptionHandler(MalformedJwtException.class)
    public ResponseEntity<ErrorResponse> handleMalformedJwtException(MalformedJwtException e) {
        ErrorResponse error = ErrorResponse.builder()
            .message("Invalid token format")
            .errorCode("JWT_MALFORMED")
            .timestamp(LocalDateTime.now())
            .build();
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
    }
}

Альтернативы JWT

1. Opaque Tokens

  • Случайные строки, не содержащие информации
  • Требуют обращения к базе данных для валидации
  • Легко отзываются
  • Подходят для высоконагруженных систем с централизованной аутентификацией

2. Session-based Authentication

  • Традиционный подход с сессиями
  • Состояние хранится на сервере
  • Проще в реализации
  • Проблемы с масштабируемостью в distributed системах

3. OAuth 2.0 / OpenID Connect

  • Стандарт для делегированной авторизации
  • Подходит для интеграции с внешними провайдерами
  • Более сложная реализация
  • Поддерживает различные flows (authorization code, implicit, etc.)

Частые вопросы на собеседовании

Q: Можно ли отозвать JWT токен? A: JWT токены stateless, поэтому их нельзя отозвать без дополнительной инфраструктуры. Решения: blacklist в Redis, короткий TTL, refresh token pattern.

Q: Где хранить JWT токены в браузере? A: Лучший вариант — httpOnly cookies для защиты от XSS. LocalStorage уязвим для XSS атак, но удобен для SPA.

Q: Почему JWT не подходит для хранения конфиденциальной информации? A: JWT токены только подписаны, но не зашифрованы. Payload можно декодировать через base64. Для конфиденциальности используйте JWE (JSON Web Encryption).

Q: Как масштабировать JWT в микросервисной архитектуре? A: Используйте общий секретный ключ или асимметричные ключи (RS256). Каждый сервис может независимо валидировать токены без обращения к auth сервису.

OAuth 2.0

Что такое OAuth 2.0?

OAuth 2.0 — это протокол авторизации, который позволяет приложениям получить ограниченный доступ к ресурсам пользователя без получения его пароля. Основная идея: пользователь может разрешить третьему приложению доступ к своим данным, не раскрывая учетные данные.

Ключевые принципы:

  • Делегирование авторизации — пользователь делегирует права доступа третьему приложению
  • Ограниченный доступ — можно запросить только определенные разрешения (scopes)
  • Безопасность — пароли не передаются третьим приложениям
  • Отзыв доступа — пользователь может отозвать разрешения в любое время

Участники OAuth 2.0

1. Resource Owner (Владелец ресурса)

Кто это: Пользователь, который владеет данными или ресурсами. Может предоставить доступ к своим ресурсам третьим приложениям.

Пример: Пользователь Facebook, который разрешает мобильному приложению доступ к своему профилю.

2. Client (Клиент)

Кто это: Приложение, которое запрашивает доступ к ресурсам от имени пользователя. Должно быть зарегистрировано у Authorization Server.

Типы клиентов:

  • Confidential Client — может безопасно хранить секреты (серверные приложения)
  • Public Client — не может безопасно хранить секреты (мобильные/SPA приложения)

3. Authorization Server (Сервер авторизации)

Кто это: Сервер, который аутентифицирует пользователя и выдает access tokens клиентам после получения авторизации.

Функции:

  • Регистрация клиентов
  • Аутентификация пользователей
  • Выдача и валидация токенов
  • Управление разрешениями (scopes)

4. Resource Server (Сервер ресурсов)

Кто это: Сервер, который хранит защищенные ресурсы и может принимать и отвечать на запросы с access tokens.

Функции:

  • Валидация access tokens
  • Предоставление доступа к ресурсам
  • Проверка разрешений (scopes)

Grant Types (Типы предоставления доступа)

1. Authorization Code Grant

Самый безопасный и популярный flow для веб-приложений.

// Шаг 1: Перенаправление на Authorization Server
@GetMapping("/login")
public String login() {
    String authUrl = "https://auth-server.com/oauth/authorize?" +
        "response_type=code&" +
        "client_id=your_client_id&" +
        "redirect_uri=https://your-app.com/callback&" +
        "scope=read write&" +
        "state=random_state_value";
    
    return "redirect:" + authUrl;
}

// Шаг 2: Обработка callback с authorization code
@GetMapping("/callback")
public String callback(@RequestParam String code, @RequestParam String state) {
    // Валидация state parameter для защиты от CSRF
    if (!validateState(state)) {
        throw new SecurityException("Invalid state parameter");
    }
    
    // Обмен authorization code на access token
    String accessToken = exchangeCodeForToken(code);
    
    return "redirect:/dashboard";
}

Процесс:

  1. Клиент перенаправляет пользователя на Authorization Server
  2. Пользователь аутентифицируется и дает согласие
  3. Authorization Server возвращает authorization code
  4. Клиент обменивает code на access token через back-channel

Преимущества: Максимальная безопасность, access token не передается через браузер Недостатки: Требует back-channel для обмена кода на токен

2. Implicit Grant

Упрощенный flow для SPA приложений (устаревший, не рекомендуется).

// Прямое получение access token во frontend
// https://auth-server.com/oauth/authorize?
// response_type=token&
// client_id=your_client_id&
// redirect_uri=https://your-spa.com/callback&
// scope=read&
// state=random_state_value

// Токен возвращается в URL fragment
// https://your-spa.com/callback#access_token=TOKEN&token_type=Bearer&expires_in=3600

Проблемы безопасности:

  • Access token передается через URL
  • Нет аутентификации клиента
  • Нет возможности использовать refresh token

3. Resource Owner Password Credentials Grant

Для доверенных приложений, где клиент может получить учетные данные пользователя.

@PostMapping("/token")
public ResponseEntity<TokenResponse> getToken(@RequestBody PasswordGrantRequest request) {
    // Валидация учетных данных пользователя
    if (!userService.validateCredentials(request.getUsername(), request.getPassword())) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
    
    // Генерация access token
    String accessToken = tokenService.generateAccessToken(request.getUsername());
    String refreshToken = tokenService.generateRefreshToken(request.getUsername());
    
    TokenResponse response = TokenResponse.builder()
        .accessToken(accessToken)
        .refreshToken(refreshToken)
        .tokenType("Bearer")
        .expiresIn(3600)
        .build();
    
    return ResponseEntity.ok(response);
}

Когда использовать: Только для первых приложений от того же издателя (например, мобильное приложение банка)

4. Client Credentials Grant

Для server-to-server взаимодействия, когда нет конечного пользователя.

@PostMapping("/token")
public ResponseEntity<TokenResponse> getClientToken(@RequestBody ClientCredentialsRequest request) {
    // Аутентификация клиента
    if (!clientService.validateClient(request.getClientId(), request.getClientSecret())) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
    
    // Генерация access token для клиента
    String accessToken = tokenService.generateClientToken(request.getClientId(), request.getScope());
    
    TokenResponse response = TokenResponse.builder()
        .accessToken(accessToken)
        .tokenType("Bearer")
        .expiresIn(3600)
        .build();
    
    return ResponseEntity.ok(response);
}

Применение: Микросервисы, API-to-API взаимодействие, фоновые задачи

5. Refresh Token Grant

Для получения нового access token без повторной аутентификации пользователя.

@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refreshToken(@RequestBody RefreshTokenRequest request) {
    // Валидация refresh token
    RefreshToken storedToken = refreshTokenService.findByToken(request.getRefreshToken());
    if (storedToken == null || storedToken.isExpired()) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
    
    // Генерация нового access token
    String newAccessToken = tokenService.generateAccessToken(storedToken.getUserId());
    
    TokenResponse response = TokenResponse.builder()
        .accessToken(newAccessToken)
        .tokenType("Bearer")
        .expiresIn(3600)
        .build();
    
    return ResponseEntity.ok(response);
}

Реализация Authorization Server в Spring

Зависимости

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Spring Authorization Server — официальная реализация OAuth 2.0 Authorization Server от Spring. Заменил устаревший Spring Security OAuth.

Конфигурация Authorization Server

@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {
    
    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("messaging-client")
            .clientSecret("{noop}secret")
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
            .scope(OidcScopes.OPENID)
            .scope("message.read")
            .scope("message.write")
            .clientSettings(ClientSettings.builder()
                .requireAuthorizationConsent(true)
                .build())
            .build();
        
        return new InMemoryRegisteredClientRepository(registeredClient);
    }
    
    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        
        RSAKey rsaKey = new RSAKey.Builder(publicKey)
            .privateKey(privateKey)
            .keyID(UUID.randomUUID().toString())
            .build();
        
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }
    
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
            .issuer("http://localhost:9000")
            .build();
    }
}

RegisteredClientRepository — хранилище зарегистрированных клиентов. В продакшене используйте JdbcRegisteredClientRepository.

JWKSource — источник ключей для подписи JWT токенов. Публичные ключи доступны через endpoint /.well-known/jwks.json.

Кастомизация токенов

@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
    return context -> {
        if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
            Authentication principal = context.getPrincipal();
            Set<String> authorities = principal.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toSet());
            
            context.getClaims().claim("roles", authorities);
            context.getClaims().claim("custom_claim", "custom_value");
        }
    };
}

Реализация Resource Server

Конфигурация Resource Server

@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/messages").hasAuthority("SCOPE_message.read")
                .requestMatchers(HttpMethod.POST, "/api/messages").hasAuthority("SCOPE_message.write")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            );
        
        return http.build();
    }
    
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
        authoritiesConverter.setAuthorityPrefix("SCOPE_");
        authoritiesConverter.setAuthoritiesClaimName("scope");
        
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
        return converter;
    }
}

OAuth2ResourceServer — автоматически валидирует JWT токены и извлекает информацию о пользователе и разрешениях.

Конфигурация для валидации токенов

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9000
          # Альтернативно можно указать jwk-set-uri
          # jwk-set-uri: http://localhost:9000/.well-known/jwks.json

Scopes (Области доступа)

Определение и использование scopes

public enum OAuth2Scope {
    READ("read", "Read access to resources"),
    WRITE("write", "Write access to resources"),
    DELETE("delete", "Delete access to resources"),
    ADMIN("admin", "Administrative access");
    
    private final String value;
    private final String description;
    
    OAuth2Scope(String value, String description) {
        this.value = value;
        this.description = description;
    }
}

@RestController
@RequestMapping("/api/messages")
public class MessageController {
    
    @GetMapping
    @PreAuthorize("hasAuthority('SCOPE_message.read')")
    public List<Message> getMessages() {
        return messageService.getAllMessages();
    }
    
    @PostMapping
    @PreAuthorize("hasAuthority('SCOPE_message.write')")
    public Message createMessage(@RequestBody Message message) {
        return messageService.createMessage(message);
    }
}

Scopes определяют, какие операции может выполнять клиент от имени пользователя. Это основной механизм ограничения доступа в OAuth 2.0.

PKCE (Proof Key for Code Exchange)

Реализация PKCE для безопасности

@Service
public class PKCEService {
    
    public PKCEChallenge generatePKCEChallenge() {
        // Генерация code verifier (случайная строка 43-128 символов)
        String codeVerifier = generateRandomString(128);
        
        // Создание code challenge (SHA256 hash + base64url encoding)
        String codeChallenge = base64UrlEncode(sha256(codeVerifier));
        
        return PKCEChallenge.builder()
            .codeVerifier(codeVerifier)
            .codeChallenge(codeChallenge)
            .codeChallengeMethod("S256")
            .build();
    }
    
    public boolean validatePKCE(String codeVerifier, String codeChallenge) {
        String computedChallenge = base64UrlEncode(sha256(codeVerifier));
        return computedChallenge.equals(codeChallenge);
    }
    
    private String generateRandomString(int length) {
        SecureRandom random = new SecureRandom();
        byte[] bytes = new byte[length];
        random.nextBytes(bytes);
        return base64UrlEncode(bytes);
    }
}

PKCE — расширение OAuth 2.0 для защиты от атак перехвата authorization code. Обязательно для публичных клиентов (мобильные приложения, SPA).

Процесс:

  1. Клиент генерирует code_verifier и code_challenge
  2. В запросе авторизации отправляется code_challenge
  3. При обмене кода на токен отправляется code_verifier
  4. Сервер проверяет соответствие code_verifier и code_challenge

OpenID Connect (OIDC)

Расширение OAuth 2.0 для аутентификации

@RestController
public class UserInfoController {
    
    @GetMapping("/userinfo")
    public ResponseEntity<UserInfo> getUserInfo(Authentication authentication) {
        if (authentication instanceof JwtAuthenticationToken) {
            JwtAuthenticationToken token = (JwtAuthenticationToken) authentication;
            Jwt jwt = token.getToken();
            
            UserInfo userInfo = UserInfo.builder()
                .sub(jwt.getSubject())
                .name(jwt.getClaimAsString("name"))
                .email(jwt.getClaimAsString("email"))
                .emailVerified(jwt.getClaimAsBoolean("email_verified"))
                .picture(jwt.getClaimAsString("picture"))
                .build();
            
            return ResponseEntity.ok(userInfo);
        }
        
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
}

OpenID Connect — надстройка над OAuth 2.0 для аутентификации пользователей. Добавляет:

  • ID Token — JWT токен с информацией о пользователе
  • UserInfo endpoint — для получения дополнительной информации о пользователе
  • Стандартные claims — sub, name, email, picture и др.

Стандартные OIDC scopes

public class OidcScopes {
    public static final String OPENID = "openid";      // Обязательный для OIDC
    public static final String PROFILE = "profile";    // Базовая информация профиля
    public static final String EMAIL = "email";        // Email и email_verified
    public static final String ADDRESS = "address";    // Адрес пользователя
    public static final String PHONE = "phone";        // Номер телефона
}

Безопасность OAuth 2.0

Защита от основных атак

@Component
public class OAuth2SecurityService {
    
    // Защита от CSRF через state parameter
    public String generateState() {
        return UUID.randomUUID().toString();
    }
    
    public boolean validateState(String receivedState, String expectedState) {
        return receivedState != null && receivedState.equals(expectedState);
    }
    
    // Защита от replay attacks
    public boolean validateNonce(String nonce, String storedNonce) {
        return nonce != null && nonce.equals(storedNonce);
    }
    
    // Валидация redirect URI
    public boolean validateRedirectUri(String redirectUri, List<String> allowedRedirectUris) {
        return allowedRedirectUris.contains(redirectUri);
    }
    
    // Ограничение времени жизни authorization code
    public boolean isAuthorizationCodeExpired(AuthorizationCode code) {
        return code.getExpiresAt().isBefore(LocalDateTime.now());
    }
}

Лучшие практики

  1. Используйте HTTPS везде — OAuth 2.0 критически зависит от TLS
  2. Валидируйте redirect_uri — предотвращает атаки перенаправления
  3. Используйте state parameter — защита от CSRF атак
  4. Короткий TTL для authorization codes — обычно 10 минут
  5. Одноразовое использование authorization codes — код нельзя использовать повторно
  6. Храните client secrets безопасно — только для confidential clients

Интеграция с внешними провайдерами

Конфигурация OAuth 2.0 Client

@Configuration
public class OAuth2ClientConfig {
    
    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        return new InMemoryClientRegistrationRepository(
            googleClientRegistration(),
            githubClientRegistration()
        );
    }
    
    private ClientRegistration googleClientRegistration() {
        return ClientRegistration.withRegistrationId("google")
            .clientId("your-google-client-id")
            .clientSecret("your-google-client-secret")
            .scope("openid", "profile", "email")
            .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
            .tokenUri("https://oauth2.googleapis.com/token")
            .userInfoUri("https://openidconnect.googleapis.com/v1/userinfo")
            .userNameAttributeName(IdTokenClaimNames.SUB)
            .jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
            .clientName("Google")
            .build();
    }
}

Обработка OAuth 2.0 Login

@Controller
public class OAuth2LoginController {
    
    @GetMapping("/login/oauth2/callback")
    public String oauth2LoginCallback(OAuth2AuthenticationToken authentication) {
        OAuth2User user = authentication.getPrincipal();
        
        // Извлечение информации о пользователе
        String email = user.getAttribute("email");
        String name = user.getAttribute("name");
        String providerId = user.getAttribute("sub");
        
        // Создание или обновление пользователя в системе
        User systemUser = userService.createOrUpdateUser(email, name, providerId);
        
        // Генерация собственного JWT токена
        String jwtToken = jwtService.generateToken(systemUser);
        
        return "redirect:/dashboard?token=" + jwtToken;
    }
}

Частые вопросы на собеседовании

Q: В чем разница между OAuth 2.0 и OpenID Connect? A: OAuth 2.0 — протокол авторизации (что может делать), OpenID Connect — расширение для аутентификации (кто это). OIDC добавляет ID Token и UserInfo endpoint.

Q: Почему Implicit Grant считается небезопасным? A: Access token передается через URL fragment, что делает его видимым в логах, истории браузера и referrer headers. Нет аутентификации клиента.

Q: Что такое PKCE и зачем он нужен? A: Proof Key for Code Exchange — защита от атак перехвата authorization code для публичных клиентов. Клиент доказывает, что именно он инициировал запрос.

Q: Как отозвать OAuth 2.0 токен? A: Через Token Revocation endpoint (RFC 7009) или через blacklist механизм. Refresh tokens обычно отзываются через базу данных.

Q: В чем разница между scope и роли? A: Scope определяет, что может делать приложение от имени пользователя. Роли определяют, что может делать сам пользователь в системе.

Q: Как масштабировать OAuth 2.0 в микросервисной архитектуре? A: Централизованный Authorization Server с JWT токенами. Каждый микросервис может независимо валидировать токены через публичные ключи (JWK Set).

Keycloak

Что такое Keycloak?

Keycloak — это Open Source Identity and Access Management (IAM) решение от Red Hat, которое предоставляет готовый Authorization Server с поддержкой OAuth 2.0, OpenID Connect, SAML 2.0 и других современных протоколов аутентификации и авторизации.

Основные возможности:

  • Single Sign-On (SSO) — один вход для всех приложений
  • Identity Brokering — интеграция с внешними провайдерами (Google, Facebook, LDAP)
  • User Federation — синхронизация с существующими базами пользователей
  • Fine-grained Authorization — детальное управление доступом на уровне ресурсов
  • Admin Console — веб-интерфейс для управления
  • Account Management — самообслуживание для пользователей

Архитектура Keycloak

Основные компоненты

Realm (Область) — изолированная область управления пользователями, ролями, группами и клиентами. Каждый realm независим и имеет свои настройки безопасности.

Client (Клиент) — приложение или сервис, который использует Keycloak для аутентификации. Может быть веб-приложением, мобильным приложением или микросервисом.

User (Пользователь) — сущность, которая может аутентифицироваться в системе. Имеет атрибуты, роли и принадлежит к группам.

Role (Роль) — набор разрешений, который может быть назначен пользователю или группе. Существуют realm roles и client roles.

Group (Группа) — коллекция пользователей, которой можно назначить роли и атрибуты одновременно.

Установка и настройка

Docker Compose для разработки

version: '3.8'
services:
  keycloak:
    image: quay.io/keycloak/keycloak:22.0
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: password
    ports:

      - "8080:8080"
    command: start-dev
    depends_on:

      - postgres
    
  postgres:
    image: postgres:15
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: password
    volumes:

      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Команда start-dev запускает Keycloak в режиме разработки с отключенными некоторыми проверками безопасности. В продакшене используйте start с полной конфигурацией.

Конфигурация для Production

# Переменные окружения для production
export KC_DB=postgres
export KC_DB_URL=jdbc:postgresql://localhost/keycloak
export KC_DB_USERNAME=keycloak
export KC_DB_PASSWORD=password
export KC_HOSTNAME=auth.yourcompany.com
export KC_HTTPS_CERTIFICATE_FILE=/path/to/cert.pem
export KC_HTTPS_CERTIFICATE_KEY_FILE=/path/to/key.pem

# Запуск в production режиме
./kc.sh start --optimized

Интеграция с Spring Boot

Зависимости Maven

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-spring-boot-starter</artifactId>
    <version>22.0.1</version>
</dependency>

spring-boot-starter-oauth2-resource-server — для валидации JWT токенов от Keycloak spring-boot-starter-oauth2-client — для OAuth 2.0 login flow keycloak-spring-boot-starter — дополнительные возможности интеграции с Keycloak

Конфигурация Resource Server

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8080/realms/my-realm
          jwk-set-uri: http://localhost:8080/realms/my-realm/protocol/openid-connect/certs
      client:
        registration:
          keycloak:
            client-id: my-app
            client-secret: your-client-secret
            scope: openid,profile,email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            issuer-uri: http://localhost:8080/realms/my-realm
            user-name-attribute: preferred_username

issuer-uri — базовый URL realm'а в Keycloak для автоматической конфигурации jwk-set-uri — endpoint с публичными ключами для валидации JWT подписей

Security Configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            )
            .oauth2Login(oauth2 -> oauth2
                .defaultSuccessUrl("/dashboard", true)
            );
        
        return http.build();
    }
    
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
        authoritiesConverter.setAuthorityPrefix("ROLE_");
        authoritiesConverter.setAuthoritiesClaimName("realm_access.roles");
        
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
        return converter;
    }
}

JwtAuthenticationConverter — преобразует claims из JWT токена в Spring Security authorities. realm_access.roles — стандартное местоположение ролей в Keycloak JWT.

Управление пользователями через Admin REST API

Keycloak Admin Client

@Service
public class KeycloakUserService {
    
    @Value("${keycloak.auth-server-url}")
    private String serverUrl;
    
    @Value("${keycloak.realm}")
    private String realm;
    
    @Value("${keycloak.resource}")
    private String clientId;
    
    @Value("${keycloak.credentials.secret}")
    private String clientSecret;
    
    private Keycloak getKeycloakInstance() {
        return KeycloakBuilder.builder()
            .serverUrl(serverUrl)
            .realm("master")  // Используем master realm для админ операций
            .clientId("admin-cli")
            .username("admin")
            .password("admin")
            .build();
    }
    
    public String createUser(UserCreateRequest request) {
        Keycloak keycloak = getKeycloakInstance();
        
        UserRepresentation user = new UserRepresentation();
        user.setUsername(request.getUsername());
        user.setEmail(request.getEmail());
        user.setFirstName(request.getFirstName());
        user.setLastName(request.getLastName());
        user.setEnabled(true);
        user.setEmailVerified(true);
        
        // Создание пользователя
        Response response = keycloak.realm(realm)
            .users()
            .create(user);
        
        if (response.getStatus() == 201) {
            String userId = getCreatedId(response);
            
            // Установка пароля
            CredentialRepresentation credential = new CredentialRepresentation();
            credential.setType(CredentialRepresentation.PASSWORD);
            credential.setValue(request.getPassword());
            credential.setTemporary(false);
            
            keycloak.realm(realm)
                .users()
                .get(userId)
                .resetPassword(credential);
            
            return userId;
        }
        
        throw new RuntimeException("Failed to create user");
    }
    
    public void assignRoleToUser(String userId, String roleName) {
        Keycloak keycloak = getKeycloakInstance();
        
        RoleRepresentation role = keycloak.realm(realm)
            .roles()
            .get(roleName)
            .toRepresentation();
        
        keycloak.realm(realm)
            .users()
            .get(userId)
            .roles()
            .realmLevel()
            .add(Collections.singletonList(role));
    }
}

Keycloak Admin Client — Java библиотека для программного управления Keycloak через REST API. Позволяет создавать пользователей, назначать роли, управлять группами без использования веб-интерфейса.

Кастомизация токенов

Protocol Mappers

Protocol Mappers — механизм добавления дополнительных claims в токены. Можно добавлять пользовательские атрибуты, роли из базы данных, вычисляемые значения.

// Пример кастомного Token Mapper через SPI
public class CustomTokenMapper implements ProtocolMapper, OIDCAccessTokenMapper {
    
    public static final String PROVIDER_ID = "custom-token-mapper";
    
    @Override
    public String getProtocol() {
        return OIDCLoginProtocol.LOGIN_PROTOCOL;
    }
    
    @Override
    public String getDisplayType() {
        return "Custom Token Mapper";
    }
    
    @Override
    public AccessToken transformAccessToken(AccessToken token, 
                                          ProtocolMapperModel mappingModel, 
                                          KeycloakSession session,
                                          UserSessionModel userSession, 
                                          ClientSessionContext clientSessionCtx) {
        
        UserModel user = userSession.getUser();
        
        // Добавление кастомных claims
        token.getOtherClaims().put("department", user.getFirstAttribute("department"));
        token.getOtherClaims().put("employee_id", user.getFirstAttribute("employeeId"));
        
        // Добавление ролей из внешней системы
        List<String> externalRoles = getExternalRoles(user.getUsername());
        token.getOtherClaims().put("external_roles", externalRoles);
        
        return token;
    }
}

User Attribute Mappers

// Добавление пользовательских атрибутов через REST API
public void addUserAttributes(String userId, Map<String, String> attributes) {
    Keycloak keycloak = getKeycloakInstance();
    
    UserRepresentation user = keycloak.realm(realm)
        .users()
        .get(userId)
        .toRepresentation();
    
    if (user.getAttributes() == null) {
        user.setAttributes(new HashMap<>());
    }
    
    attributes.forEach((key, value) -> 
        user.getAttributes().put(key, Collections.singletonList(value))
    );
    
    keycloak.realm(realm)
        .users()
        .get(userId)
        .update(user);
}

User Federation и Identity Providers

LDAP Integration

User Federation позволяет интегрировать Keycloak с существующими базами пользователей (LDAP, Active Directory, базы данных).

{
  "id": "ldap-provider",
  "displayName": "Corporate LDAP",
  "providerId": "ldap",
  "providerType": "org.keycloak.storage.UserStorageProvider",
  "config": {
    "connectionUrl": ["ldap://ldap.company.com:389"],
    "usersDn": ["ou=users,dc=company,dc=com"],
    "bindDn": ["cn=admin,dc=company,dc=com"],
    "bindCredential": ["admin-password"],
    "userObjectClasses": ["inetOrgPerson"],
    "usernameLDAPAttribute": ["uid"],
    "emailLDAPAttribute": ["mail"],
    "firstNameLDAPAttribute": ["givenName"],
    "lastNameLDAPAttribute": ["sn"]
  }
}

Преимущества User Federation:

  • Централизованное управление пользователями
  • Синхронизация паролей и атрибутов
  • Постепенная миграция на Keycloak
  • Кэширование для производительности

Social Identity Providers

// Конфигурация Google Identity Provider через Admin API
public void configureGoogleProvider() {
    Keycloak keycloak = getKeycloakInstance();
    
    IdentityProviderRepresentation provider = new IdentityProviderRepresentation();
    provider.setAlias("google");
    provider.setProviderId("google");
    provider.setEnabled(true);
    provider.setTrustEmail(true);
    provider.setStoreToken(false);
    
    Map<String, String> config = new HashMap<>();
    config.put("clientId", "your-google-client-id");
    config.put("clientSecret", "your-google-client-secret");
    config.put("defaultScope", "openid profile email");
    
    provider.setConfig(config);
    
    keycloak.realm(realm)
        .identityProviders()
        .create(provider);
}

Authorization Services (UMA 2.0)

Защита ресурсов

Authorization Services — расширенная система авторизации в Keycloak, основанная на User-Managed Access (UMA 2.0). Позволяет создавать fine-grained политики доступа.

@RestController
@RequestMapping("/api/documents")
public class DocumentController {
    
    @Autowired
    private KeycloakAuthorizationService authorizationService;
    
    @GetMapping("/{documentId}")
    public ResponseEntity<Document> getDocument(@PathVariable String documentId,
                                               Authentication authentication) {
        
        // Проверка разрешений через Keycloak Policy Decision Point
        AuthorizationRequest authRequest = AuthorizationRequest.builder()
            .resource("document")
            .resourceId(documentId)
            .scope("read")
            .accessToken(getAccessToken(authentication))
            .build();
        
        AuthorizationResponse authResponse = authorizationService.authorize(authRequest);
        
        if (authResponse.isAuthorized()) {
            Document document = documentService.getDocument(documentId);
            return ResponseEntity.ok(document);
        } else {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
    }
}

Policy-Based Access Control

// Создание ресурса и политик через Admin API
public void createResourceWithPolicies() {
    Keycloak keycloak = getKeycloakInstance();
    
    // Создание ресурса
    ResourceRepresentation resource = new ResourceRepresentation();
    resource.setName("sensitive-document");
    resource.setDisplayName("Sensitive Document");
    resource.setUri("/api/documents/sensitive/*");
    resource.setScopes(Set.of(
        new ScopeRepresentation("read"),
        new ScopeRepresentation("write"),
        new ScopeRepresentation("delete")
    ));
    
    // Создание роль-based политики
    RolePolicyRepresentation rolePolicy = new RolePolicyRepresentation();
    rolePolicy.setName("manager-policy");
    rolePolicy.addRole("manager");
    rolePolicy.setLogic(Logic.POSITIVE);
    
    // Создание time-based политики
    TimePolicyRepresentation timePolicy = new TimePolicyRepresentation();
    timePolicy.setName("business-hours-policy");
    timePolicy.setNotBefore("09:00:00");
    timePolicy.setNotOnOrAfter("18:00:00");
    
    // Создание permission
    ResourcePermissionRepresentation permission = new ResourcePermissionRepresentation();
    permission.setName("sensitive-document-permission");
    permission.addResource("sensitive-document");
    permission.addPolicy("manager-policy");
    permission.addPolicy("business-hours-policy");
    permission.setDecisionStrategy(DecisionStrategy.UNANIMOUS);
}

Типы политик:

  • Role-based — на основе ролей пользователя
  • Attribute-based — на основе атрибутов пользователя или ресурса
  • Time-based — временные ограничения
  • JavaScript — кастомная логика на JavaScript
  • Group-based — на основе групп пользователя

Event Listeners и Audit

Custom Event Listener

@Component
public class CustomEventListener implements EventListenerProvider {
    
    private static final Logger logger = LoggerFactory.getLogger(CustomEventListener.class);
    
    @Override
    public void onEvent(Event event) {
        switch (event.getType()) {
            case LOGIN:
                handleLoginEvent(event);
                break;
            case LOGIN_ERROR:
                handleLoginError(event);
                break;
            case LOGOUT:
                handleLogoutEvent(event);
                break;
            case UPDATE_PASSWORD:
                handlePasswordUpdate(event);
                break;
        }
    }
    
    private void handleLoginEvent(Event event) {
        String userId = event.getUserId();
        String ipAddress = event.getIpAddress();
        String clientId = event.getClientId();
        
        // Логирование успешного входа
        logger.info("User {} logged in from {} to client {}", userId, ipAddress, clientId);
        
        // Отправка в систему мониторинга
        auditService.recordLoginEvent(userId, ipAddress, clientId);
        
        // Проверка на подозрительную активность
        if (securityService.isSuspiciousLogin(userId, ipAddress)) {
            securityService.triggerSecurityAlert(userId, ipAddress);
        }
    }
    
    private void handleLoginError(Event event) {
        String error = event.getError();
        String username = event.getDetails().get(Details.USERNAME);
        String ipAddress = event.getIpAddress();
        
        logger.warn("Login failed for user {} from {}: {}", username, ipAddress, error);
        
        // Подсчет неудачных попыток для защиты от брут-форса
        bruteForceService.recordFailedAttempt(username, ipAddress);
    }
}

Event Listener SPI — позволяет реагировать на события в Keycloak (логин, логаут, изменение пароля и др.) для аудита, мониторинга и интеграции с внешними системами.

Кастомные Themes

Кастомизация интерфейса

<!-- login.ftl - кастомная страница логина -->
<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=social.displayInfo displayWide=(realm.password && social.providers??); section>
    <#if section = "header">
        ${msg("doLogIn")}
    <#elseif section = "form">
        <div id="kc-form" <#if realm.password && social.providers??>class="${properties.kcContentWrapperClass!}"</#if>>
            <div id="kc-form-wrapper" <#if realm.password && social.providers??>class="${properties.kcFormSocialAccountContentClass!} ${properties.kcFormSocialAccountClass!}"</#if>>
                <#if realm.password>
                    <form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post">
                        <div class="company-logo">
                            <img src="${url.resourcesPath}/img/company-logo.png" alt="Company Logo" />
                        </div>
                        
                        <div class="${properties.kcFormGroupClass!}">
                            <label for="username" class="${properties.kcLabelClass!}">
                                <#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if>
                            </label>
                            <input tabindex="1" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text" autofocus autocomplete="off" />
                        </div>

                        <div class="${properties.kcFormGroupClass!}">
                            <label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>
                            <input tabindex="2" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="off" />
                        </div>

                        <div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
                            <div id="kc-form-options">
                                <#if realm.rememberMe && !usernameEditDisabled??>
                                    <div class="checkbox">
                                        <label>
                                            <#if login.rememberMe??>
                                                <input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox" checked> ${msg("rememberMe")}
                                            <#else>
                                                <input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox"> ${msg("rememberMe")}
                                            </#if>
                                        </label>
                                    </div>
                                </#if>
                            </div>
                        </div>

                        <div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
                            <input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
                            <input tabindex="4" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
                        </div>
                    </form>
                </#if>
            </div>
        </div>
    </#elseif>
</@layout.registrationLayout>

Themes — позволяют кастомизировать внешний вид всех страниц Keycloak (логин, регистрация, аккаунт управления). Используют FreeMarker templates и CSS.

Масштабирование и High Availability

Кластеризация Keycloak

# docker-compose для кластера Keycloak
version: '3.8'
services:
  keycloak-1:
    image: quay.io/keycloak/keycloak:22.0
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: password
      KC_CACHE: ispn
      KC_CACHE_STACK: tcp
      JGROUPS_DISCOVERY_PROTOCOL: JDBC_PING
    ports:

      - "8080:8080"
    
  keycloak-2:
    image: quay.io/keycloak/keycloak:22.0
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: password
      KC_CACHE: ispn
      KC_CACHE_STACK: tcp
      JGROUPS_DISCOVERY_PROTOCOL: JDBC_PING
    ports:

      - "8081:8080"
    
  nginx:
    image: nginx:alpine
    ports:

      - "80:80"
    volumes:

      - ./nginx.conf:/etc/nginx/nginx.conf

JDBC_PING — механизм обнаружения узлов кластера через базу данных. Infinispan — встроенная система кэширования для синхронизации сессий между узлами.

Мониторинг и метрики

// Кастомные метрики для мониторинга Keycloak
@Component
public class KeycloakMetrics {
    
    private final MeterRegistry meterRegistry;
    private final Counter loginSuccessCounter;
    private final Counter loginFailureCounter;
    private final Timer tokenGenerationTimer;
    
    public KeycloakMetrics(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.loginSuccessCounter = Counter.builder("keycloak.login.success")
            .description("Number of successful logins")
            .register(meterRegistry);
        
        this.loginFailureCounter = Counter.builder("keycloak.login.failure")
            .description("Number of failed logins")
            .register(meterRegistry);
        
        this.tokenGenerationTimer = Timer.builder("keycloak.token.generation")
            .description("Time taken to generate tokens")
            .register(meterRegistry);
    }
    
    public void recordSuccessfulLogin(String realm, String client) {
        loginSuccessCounter.increment(
            Tags.of("realm", realm, "client", client)
        );
    }
    
    public void recordFailedLogin(String realm, String error) {
        loginFailureCounter.increment(
            Tags.of("realm", realm, "error", error)
        );
    }
}

Частые вопросы на собеседовании

Q: В чем разница между Realm Roles и Client Roles в Keycloak? A: Realm Roles — глобальные роли для всего realm'а, доступны всем клиентам. Client Roles — специфичны для конкретного клиента и не видны другим клиентам.

Q: Как Keycloak обеспечивает SSO между приложениями? A: Через cookie сессии браузера и перенаправления. При первом логине создается сессия, последующие приложения проверяют наличие активной сессии через iframe или redirect.

Q: Что такое User Federation и когда его использовать? A: Механизм интеграции с существующими системами хранения пользователей (LDAP, AD, БД). Используется при миграции на Keycloak или когда нужно сохранить существующую систему управления пользователями.

Q: Как масштабировать Keycloak для высоких нагрузок? A: Кластеризация с Infinispan для синхронизации сессий, использование внешней БД, кэширование, load balancer с sticky sessions, оптимизация JVM параметров.

Q: В чем разница между Authorization Services и обычными ролями? A: Роли — простая проверка принадлежности. Authorization Services — fine-grained авторизация с политиками, ресурсами, разрешениями, поддержка dynamic permissions и UMA 2.0.

Q: Как обеспечить безопасность Keycloak в production? A: HTTPS везде, сильные пароли админов, регулярные обновления, firewall правила, мониторинг событий, резервное копирование БД, отключение dev режима.

HTTPS

Что такое HTTPS?

HTTPS (HTTP Secure) — это HTTP протокол, работающий поверх TLS/SSL для обеспечения шифрования, аутентификации и целостности данных между клиентом и сервером. HTTPS использует порт 443 по умолчанию.

Основные принципы безопасности:

  • Конфиденциальность — данные зашифрованы и не могут быть прочитаны третьими лицами
  • Аутентификация — подтверждение подлинности сервера через сертификаты
  • Целостность — защита от изменения данных в процессе передачи
  • Невозможность отказа — подтверждение того, что сообщение было отправлено

TLS Handshake Process

Процесс установления соединения

TLS Handshake — это процесс согласования параметров шифрования между клиентом и сервером перед началом безопасной передачи данных.

// Упрощенная схема TLS Handshake
public class TLSHandshakeProcess {
    
    // 1. Client Hello - клиент отправляет поддерживаемые версии TLS и cipher suites
    public ClientHello sendClientHello() {
        return ClientHello.builder()
            .tlsVersion(TLSVersion.TLS_1_3)
            .cipherSuites(Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256"))
            .random(generateClientRandom())
            .build();
    }
    
    // 2. Server Hello - сервер выбирает версию TLS и cipher suite
    public ServerHello sendServerHello(ClientHello clientHello) {
        return ServerHello.builder()
            .selectedTlsVersion(TLSVersion.TLS_1_3)
            .selectedCipherSuite("TLS_AES_256_GCM_SHA384")
            .random(generateServerRandom())
            .certificate(loadServerCertificate())
            .build();
    }
    
    // 3. Key Exchange - согласование ключей шифрования
    public void performKeyExchange() {
        // В TLS 1.3 используется Elliptic Curve Diffie-Hellman
        ECDHKeyPair clientKeyPair = generateECDHKeyPair();
        ECDHKeyPair serverKeyPair = generateECDHKeyPair();
        
        // Обмен публичными ключами и вычисление общего секрета
        byte[] sharedSecret = computeSharedSecret(clientKeyPair.getPrivateKey(), 
                                                 serverKeyPair.getPublicKey());
        
        // Генерация симметричных ключей для шифрования данных
        SymmetricKeys symmetricKeys = deriveKeys(sharedSecret);
    }
}

Этапы TLS Handshake:

  1. Client Hello — клиент предлагает версии TLS и алгоритмы шифрования
  2. Server Hello — сервер выбирает параметры и отправляет сертификат
  3. Certificate Verification — клиент проверяет подлинность сертификата
  4. Key Exchange — согласование ключей для симметричного шифрования
  5. Finished — подтверждение успешного handshake

SSL/TLS Сертификаты

Типы сертификатов

X.509 Certificate — стандарт цифровых сертификатов, содержащий публичный ключ и информацию о владельце, подписанный доверенным Certificate Authority (CA).

// Работа с сертификатами в Java
public class CertificateManager {
    
    public X509Certificate loadCertificate(String certificatePath) throws Exception {
        CertificateFactory factory = CertificateFactory.getInstance("X.509");
        FileInputStream fis = new FileInputStream(certificatePath);
        X509Certificate certificate = (X509Certificate) factory.generateCertificate(fis);
        
        return certificate;
    }
    
    public boolean verifyCertificate(X509Certificate certificate, X509Certificate caCertificate) {
        try {
            // Проверка подписи сертификата
            certificate.verify(caCertificate.getPublicKey());
            
            // Проверка срока действия
            certificate.checkValidity();
            
            // Проверка отзыва через OCSP или CRL
            return !isCertificateRevoked(certificate);
            
        } catch (Exception e) {
            return false;
        }
    }
    
    public void validateCertificateChain(X509Certificate[] chain) throws Exception {
        for (int i = 0; i < chain.length - 1; i++) {
            X509Certificate current = chain[i];
            X509Certificate issuer = chain[i + 1];
            
            // Проверка, что следующий сертификат является издателем текущего
            current.verify(issuer.getPublicKey());
            
            // Проверка валидности
            current.checkValidity();
        }
    }
}

Типы сертификатов по валидации:

  • Domain Validated (DV) — проверяется только владение доменом
  • Organization Validated (OV) — дополнительно проверяется организация
  • Extended Validation (EV) — максимальная проверка, зеленая строка в браузере

Типы по покрытию доменов:

  • Single Domain — один конкретный домен
  • Wildcard — поддомены (*.example.com)
  • Multi-Domain (SAN) — несколько доменов в одном сертификате

Настройка HTTPS в Spring Boot

Конфигурация SSL в application.yml

server:
  port: 8443
  ssl:
    key-store: classpath:keystore.p12
    key-store-password: password
    key-store-type: PKCS12
    key-alias: tomcat
    # Принудительное использование HTTPS
    enabled: true
  # Редирект с HTTP на HTTPS
  http2:
    enabled: true

# Настройки безопасности
spring:
  security:
    require-ssl: true

PKCS12 — современный формат keystore, заменяющий устаревший JKS. HTTP/2 — более эффективная версия HTTP протокола, требующая HTTPS.

Программная конфигурация SSL

@Configuration
public class SSLConfig {
    
    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory() {
            @Override
            protected void postProcessContext(Context context) {
                SecurityConstraint securityConstraint = new SecurityConstraint();
                securityConstraint.setUserConstraint("CONFIDENTIAL");
                SecurityCollection collection = new SecurityCollection();
                collection.addPattern("/*");
                securityConstraint.addCollection(collection);
                context.addConstraint(securityConstraint);
            }
        };
        
        // Добавление HTTP коннектора для редиректа на HTTPS
        tomcat.addAdditionalTomcatConnectors(redirectConnector());
        return tomcat;
    }
    
    private Connector redirectConnector() {
        Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
        connector.setScheme("http");
        connector.setPort(8080);
        connector.setSecure(false);
        connector.setRedirectPort(8443);
        return connector;
    }
    
    @Bean
    public SSLContext customSSLContext() throws Exception {
        KeyStore keyStore = KeyStore.getInstance("PKCS12");
        keyStore.load(new FileInputStream("keystore.p12"), "password".toCharArray());
        
        KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
        kmf.init(keyStore, "password".toCharArray());
        
        TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        tmf.init(keyStore);
        
        SSLContext sslContext = SSLContext.getInstance("TLS");
        sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom());
        
        return sslContext;
    }
}

Создание самоподписанного сертификата

# Генерация keystore с самоподписанным сертификатом для разработки
keytool -genkeypair -alias tomcat -keyalg RSA -keysize 2048 -storetype PKCS12 \
  -keystore keystore.p12 -validity 3650 -storepass password \
  -dname "CN=localhost,OU=Development,O=Company,L=City,ST=State,C=US"

# Экспорт сертификата в PEM формате
keytool -exportcert -alias tomcat -keystore keystore.p12 -storetype PKCS12 \
  -storepass password -rfc -file certificate.pem

HTTP Security Headers

Настройка безопасности через заголовки

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .requiresChannel(channel -> 
                channel.requestMatchers(r -> r.getHeader("X-Forwarded-Proto") != null)
                       .requiresSecure())
            .headers(headers -> headers
                // Принудительное использование HTTPS
                .httpStrictTransportSecurity(hstsConfig -> hstsConfig
                    .maxAgeInSeconds(31536000)
                    .includeSubdomains(true)
                    .preload(true)
                )
                // Защита от clickjacking
                .frameOptions().deny()
                // Защита от MIME type sniffing
                .contentTypeOptions().and()
                // Защита от XSS
                .httpPublicKeyPinning(hpkpConfig -> hpkpConfig
                    .withPins("sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
                    .maxAgeInSeconds(5184000)
                )
            );
        
        return http.build();
    }
    
    @Bean
    public FilterRegistrationBean<SecurityHeadersFilter> securityHeadersFilter() {
        FilterRegistrationBean<SecurityHeadersFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new SecurityHeadersFilter());
        registrationBean.addUrlPatterns("/*");
        return registrationBean;
    }
}

@Component
public class SecurityHeadersFilter implements Filter {
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        
        // Content Security Policy
        httpResponse.setHeader("Content-Security-Policy", 
            "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'");
        
        // Referrer Policy
        httpResponse.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
        
        // Feature Policy
        httpResponse.setHeader("Permissions-Policy", 
            "camera=(), microphone=(), geolocation=()");
        
        chain.doFilter(request, response);
    }
}

Важные security headers:

  • HSTS — принуждает браузер использовать только HTTPS
  • CSP — предотвращает XSS атаки
  • X-Frame-Options — защита от clickjacking
  • X-Content-Type-Options — предотвращает MIME sniffing

Client-Side HTTPS

Настройка HTTPS клиента

@Service
public class HttpsClientService {
    
    private final RestTemplate restTemplate;
    
    public HttpsClientService() throws Exception {
        this.restTemplate = createSecureRestTemplate();
    }
    
    private RestTemplate createSecureRestTemplate() throws Exception {
        // Загрузка truststore с доверенными сертификатами
        KeyStore trustStore = KeyStore.getInstance("PKCS12");
        trustStore.load(new FileInputStream("truststore.p12"), "password".toCharArray());
        
        // Настройка SSL контекста
        SSLContext sslContext = SSLContextBuilder
            .create()
            .loadTrustMaterial(trustStore, new TrustSelfSignedStrategy())
            .build();
        
        // Создание HTTP клиента с SSL
        CloseableHttpClient httpClient = HttpClients.custom()
            .setSSLContext(sslContext)
            .setSSLHostnameVerifier(new DefaultHostnameVerifier())
            .build();
        
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
        factory.setHttpClient(httpClient);
        
        return new RestTemplate(factory);
    }
    
    // Настройка для взаимной аутентификации (mTLS)
    private RestTemplate createMutualTLSRestTemplate() throws Exception {
        // Загрузка клиентского сертификата
        KeyStore keyStore = KeyStore.getInstance("PKCS12");
        keyStore.load(new FileInputStream("client.p12"), "password".toCharArray());
        
        // Загрузка truststore
        KeyStore trustStore = KeyStore.getInstance("PKCS12");
        trustStore.load(new FileInputStream("truststore.p12"), "password".toCharArray());
        
        SSLContext sslContext = SSLContextBuilder
            .create()
            .loadKeyMaterial(keyStore, "password".toCharArray())
            .loadTrustMaterial(trustStore, null)
            .build();
        
        CloseableHttpClient httpClient = HttpClients.custom()
            .setSSLContext(sslContext)
            .build();
        
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
        factory.setHttpClient(httpClient);
        
        return new RestTemplate(factory);
    }
    
    public String makeSecureRequest(String url) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("User-Agent", "SecureApp/1.0");
        
        HttpEntity<String> entity = new HttpEntity<>(headers);
        ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
        
        return response.getBody();
    }
}

Certificate Pinning

@Component
public class CertificatePinningService {
    
    private static final String EXPECTED_PIN = "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
    
    public RestTemplate createPinnedRestTemplate() throws Exception {
        SSLContext sslContext = SSLContext.getInstance("TLS");
        sslContext.init(null, new TrustManager[]{new PinningTrustManager()}, new SecureRandom());
        
        CloseableHttpClient httpClient = HttpClients.custom()
            .setSSLContext(sslContext)
            .build();
        
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
        factory.setHttpClient(httpClient);
        
        return new RestTemplate(factory);
    }
    
    private class PinningTrustManager implements X509TrustManager {
        
        @Override
        public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
            // Стандартная проверка цепочки сертификатов
            X509TrustManager defaultTrustManager = getDefaultTrustManager();
            defaultTrustManager.checkServerTrusted(chain, authType);
            
            // Дополнительная проверка pinning
            boolean pinMatched = false;
            for (X509Certificate cert : chain) {
                String pin = calculatePin(cert);
                if (EXPECTED_PIN.equals(pin)) {
                    pinMatched = true;
                    break;
                }
            }
            
            if (!pinMatched) {
                throw new CertificateException("Certificate pin verification failed");
            }
        }
        
        private String calculatePin(X509Certificate certificate) {
            try {
                MessageDigest digest = MessageDigest.getInstance("SHA-256");
                byte[] publicKeyBytes = certificate.getPublicKey().getEncoded();
                byte[] hash = digest.digest(publicKeyBytes);
                return "sha256/" + Base64.getEncoder().encodeToString(hash);
            } catch (Exception e) {
                throw new RuntimeException("Failed to calculate certificate pin", e);
            }
        }
    }
}

Certificate Pinning — техника привязки к конкретному сертификату или публичному ключу для предотвращения MITM атак даже при компрометации CA.

Let's Encrypt и автоматизация

Автоматическое обновление сертификатов

@Service
public class CertificateRenewalService {
    
    @Scheduled(fixedRate = 86400000) // Проверка каждые 24 часа
    public void checkCertificateExpiration() {
        try {
            X509Certificate certificate = loadCurrentCertificate();
            Date expirationDate = certificate.getNotAfter();
            Date now = new Date();
            
            // Обновление за 30 дней до истечения
            long daysUntilExpiration = (expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
            
            if (daysUntilExpiration < 30) {
                renewCertificate();
            }
        } catch (Exception e) {
            log.error("Failed to check certificate expiration", e);
        }
    }
    
    private void renewCertificate() throws Exception {
        // Интеграция с ACME протоколом для Let's Encrypt
        AcmeClient acmeClient = new AcmeClient("https://acme-v02.api.letsencrypt.org/directory");
        
        // Генерация нового ключа
        KeyPair keyPair = generateKeyPair();
        
        // Создание заявки на сертификат
        CertificateRequest request = CertificateRequest.builder()
            .domains(Arrays.asList("api.example.com", "www.example.com"))
            .keyPair(keyPair)
            .build();
        
        // Прохождение challenge (HTTP-01 или DNS-01)
        Challenge challenge = acmeClient.requestChallenge(request);
        completeChallenge(challenge);
        
        // Получение нового сертификата
        X509Certificate newCertificate = acmeClient.getCertificate(request);
        
        // Обновление keystore
        updateKeystore(newCertificate, keyPair.getPrivate());
        
        // Перезагрузка SSL контекста
        reloadSSLContext();
    }
    
    private void updateKeystore(X509Certificate certificate, PrivateKey privateKey) throws Exception {
        KeyStore keyStore = KeyStore.getInstance("PKCS12");
        keyStore.load(new FileInputStream("keystore.p12"), "password".toCharArray());
        
        // Обновление сертификата и ключа
        keyStore.setKeyEntry("tomcat", privateKey, "password".toCharArray(), 
                           new Certificate[]{certificate});
        
        // Сохранение обновленного keystore
        keyStore.store(new FileOutputStream("keystore.p12"), "password".toCharArray());
    }
}

TLS версии и Cipher Suites

Конфигурация криптографических алгоритмов

@Configuration
public class TLSSecurityConfig {
    
    @Bean
    public TomcatServletWebServerFactory tomcatFactory() {
        TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
        
        factory.addConnectorCustomizers(connector -> {
            connector.setSecure(true);
            connector.setScheme("https");
            
            // Настройка поддерживаемых протоколов
            connector.setProperty("sslProtocol", "TLS");
            connector.setProperty("sslEnabledProtocols", "TLSv1.2,TLSv1.3");
            
            // Безопасные cipher suites
            connector.setProperty("ciphers", 
                "TLS_AES_256_GCM_SHA384," +
                "TLS_AES_128_GCM_SHA256," +
                "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384," +
                "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");
            
            // Принудительное использование серверных cipher suites
            connector.setProperty("useServerCipherSuitesOrder", "true");
            
            // Отключение небезопасных алгоритмов
            connector.setProperty("sslDisabledProtocols", "SSLv2,SSLv3,TLSv1,TLSv1.1");
        });
        
        return factory;
    }
    
    // Проверка безопасности TLS конфигурации
    public void validateTLSConfiguration() {
        String[] enabledProtocols = {"TLSv1.2", "TLSv1.3"};
        String[] secureCiphers = {
            "TLS_AES_256_GCM_SHA384",
            "TLS_AES_128_GCM_SHA256",
            "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
        };
        
        // Проверка на использование устаревших протоколов
        for (String protocol : enabledProtocols) {
            if (protocol.startsWith("SSL") || protocol.equals("TLSv1") || protocol.equals("TLSv1.1")) {
                throw new SecurityException("Insecure protocol detected: " + protocol);
            }
        }
    }
}

Рекомендации по TLS:

  • Только TLS 1.2+ — отключить SSL и TLS 1.0/1.1
  • Perfect Forward Secrecy — использовать ECDHE cipher suites
  • AEAD шифры — AES-GCM предпочтительнее CBC
  • Сильные хэш-функции — SHA-256 и выше

Мониторинг и диагностика

SSL/TLS логирование и мониторинг

@Component
public class SSLMonitoringService {
    
    private final MeterRegistry meterRegistry;
    private final Logger logger = LoggerFactory.getLogger(SSLMonitoringService.class);
    
    public SSLMonitoringService(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }
    
    @EventListener
    public void handleSSLHandshakeCompleted(SSLHandshakeEvent event) {
        // Метрики TLS handshake
        Timer.Sample sample = Timer.start(meterRegistry);
        sample.stop(Timer.builder("ssl.handshake.duration")
                .tag("protocol", event.getProtocol())
                .tag("cipher", event.getCipherSuite())
                .register(meterRegistry));
        
        // Логирование деталей соединения
        logger.info("SSL handshake completed: protocol={}, cipher={}, client={}", 
                   event.getProtocol(), event.getCipherSuite(), event.getClientAddress());
        
        // Проверка на слабые cipher suites
        if (isWeakCipher(event.getCipherSuite())) {
            logger.warn("Weak cipher suite used: {}", event.getCipherSuite());
            meterRegistry.counter("ssl.weak_cipher").increment();
        }
    }
    
    @Scheduled(fixedRate = 3600000) // Каждый час
    public void monitorCertificateExpiration() {
        try {
            X509Certificate certificate = getCurrentCertificate();
            long daysUntilExpiration = getDaysUntilExpiration(certificate);
            
            // Метрика времени до истечения сертификата
            meterRegistry.gauge("ssl.certificate.days_until_expiration", daysUntilExpiration);
            
            if (daysUntilExpiration < 7) {
                logger.error("Certificate expires in {} days!", daysUntilExpiration);
                sendAlertToOpsTeam(certificate, daysUntilExpiration);
            } else if (daysUntilExpiration < 30) {
                logger.warn("Certificate expires in {} days", daysUntilExpiration);
            }
        } catch (Exception e) {
            logger.error("Failed to check certificate expiration", e);
            meterRegistry.counter("ssl.certificate.check_failed").increment();
        }
    }
    
    public void validateSSLConfiguration() {
        try {
            SSLContext sslContext = SSLContext.getDefault();
            SSLEngine engine = sslContext.createSSLEngine();
            
            // Проверка поддерживаемых протоколов
            String[] supportedProtocols = engine.getSupportedProtocols();
            String[] enabledProtocols = engine.getEnabledProtocols();
            
            for (String protocol : enabledProtocols) {
                if (isInsecureProtocol(protocol)) {
                    logger.error("Insecure protocol enabled: {}", protocol);
                    meterRegistry.counter("ssl.insecure_protocol", "protocol", protocol).increment();
                }
            }
            
            // Проверка cipher suites
            String[] enabledCiphers = engine.getEnabledCipherSuites();
            for (String cipher : enabledCiphers) {
                if (isWeakCipher(cipher)) {
                    logger.warn("Weak cipher suite enabled: {}", cipher);
                }
            }
            
        } catch (Exception e) {
            logger.error("SSL configuration validation failed", e);
        }
    }
}

Частые вопросы на собеседовании

Q: В чем разница между TLS и SSL? A: SSL (1.0-3.0) — устаревший протокол с уязвимостями. TLS (1.0-1.3) — современная версия, TLS 1.2+ рекомендуется для production. SSL термин часто используется для обозначения TLS.

Q: Что происходит при TLS handshake? A: 1) Client Hello с поддерживаемыми алгоритмами, 2) Server Hello с выбранными параметрами и сертификатом, 3) проверка сертификата клиентом, 4) обмен ключами, 5) генерация симметричных ключей для шифрования данных.

Q: Что такое Perfect Forward Secrecy? A: Свойство протокола, при котором компрометация долгосрочных ключей не позволяет расшифровать предыдущие сессии. Достигается использованием эфемерных ключей (ECDHE, DHE).

Q: Как работает Certificate Pinning? A: Техника привязки к конкретному сертификату/ключу для предотвращения MITM атак. Клиент проверяет не только валидность сертификата, но и соответствие "пину" (хэшу публичного ключа).

Q: В чем разница между односторонним и взаимным TLS? A: Односторонний — только сервер предоставляет сертификат. Взаимный (mTLS) — оба участника аутентифицируются сертификатами. mTLS используется для API-to-API взаимодействия.

Q: Как оптимизировать производительность HTTPS? A: HTTP/2, session resumption, OCSP stapling, более быстрые cipher suites (AES-GCM), аппаратное ускорение, CDN с терминацией SSL.

Авторизации и аутентификации в микросервисах

1. Общий процесс аутентификации/авторизации в Kubernetes

Архитектура компонентов

[Frontend UI] → [API Gateway] → [Auth Service] → [Backend Services]
                     ↓
              [Keycloak/OAuth2 Server]

Компоненты в Kubernetes

  • Ingress Controller - точка входа, SSL termination
  • API Gateway (Kong, Istio, Zuul) - маршрутизация, аутентификация
  • Auth Service - управление пользователями, выдача токенов
  • Keycloak - Identity Provider, OAuth2/OIDC сервер
  • Backend Services - бизнес-логика с проверкой авторизации

🔄 Пошаговый процесс: Аутентификация и авторизация в микросервисах

🔹 Входные условия:

  • Фронтенд: React SPA (отдается с API Gateway)
  • API Gateway: Nginx (или Spring Cloud Gateway)
  • Backend: набор микросервисов на Spring Boot
  • Auth-сервис: отдельный микросервис
  • Kubernetes: всё работает внутри кластера
  • Keycloak (опционально): как Identity Provider

🧭 1. Открытие сайта

ПользовательБраузерGET https://app.example.com

Участвует: API Gateway (Nginx)

  • Запрос поступает в API Gateway (Nginx)
  • Gateway отдает статические файлы фронта (HTML, JS, CSS)

🔐 2. Пользователь логинится

БраузерPOST /api/auth/login (логин и пароль)

Путь запроса:

  • API Gateway (Nginx) — проксирует запрос

  • Auth-сервис — валидирует логин/пароль:

    • через внутреннюю БД или
    • через Keycloak (если используется)

Ответ:

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Или через cookies:

Set-Cookie: accessToken=...; HttpOnly; Secure; SameSite=Strict;
Set-Cookie: refreshToken=...; HttpOnly; Secure; SameSite=Strict;

🎫 3. Фронт сохраняет токены

Участвует: React Frontend

Варианты хранения:

  • В localStorage или sessionStorage
  • В HttpOnly cookie (безопаснее)

Настройка зависит от требований безопасности:

  • JWT в заголовке → подходит для API-интеграций
  • Cookie → безопаснее от XSS, но требует CSRF-защиты

📡 4. Запрос к backend-сервису

ReactGET /api/orders

Путь запроса:

  • API Gateway
    • Проксирует /api/orders на Order-сервис
    • Может сам проверять токен (если настроен)
  • Order-сервис (Spring Boot)
    • Читает заголовок: Authorization: Bearer <access_token>

    • Проверяет токен:

      • Подпись (с помощью публичного ключа)
      • exp, roles, aud
    • Авторизует пользователя по ролям

Пример проверки в Spring:

@GetMapping("/orders")
@PreAuthorize("hasRole('USER')")
public List<Order> getOrders() {
    return orderService.getUserOrders();
}

🔁 5. Access-токен истекает

ReactPOST /api/auth/refreshrefresh_token)

Путь запроса:

  • API Gateway — проксирует
  • Auth-сервис
    • Проверяет refresh_token
    • Выдает новый access_token

Автоматическое обновление:

// Axios interceptor в React
axios.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401) {
      await refreshToken();
      return axios.request(error.config);
    }
  }
);

🛠 6. Внутренние вызовы между микросервисами

Order-сервисInventory-сервисGET /internal/inventory?productId=123

Участвуют: Order-сервис, Inventory-сервис

Безопасность:

  • Добавляет Authorization: Bearer <service-token>

  • Inventory проверяет токен (обычно с ролью INTERNAL_SERVICE)

  • Дополнительная защита:

    • mTLS (Istio / Service Mesh)
    • Kubernetes NetworkPolicy
    • JWT-валидация с service-specific claims

Пример service-to-service токена:

@Service
public class ServiceTokenService {
    
    @Value("${service.token}")
    private String serviceToken;
    
    public String getServiceToken() {
        return "Bearer " + serviceToken;
    }
}

// В RestTemplate или WebClient
.header("Authorization", serviceTokenService.getServiceToken())

📊 Сводная таблица участников

Шаг Сервис(ы) Что происходит
1 API Gateway Отдает React SPA
2 Auth-сервис, Keycloak Принимает логин, выдает токены
3 React (фронт) Сохраняет токены
4 Backend-сервисы Принимают запросы с JWT, проверяют подпись/роли
5 Auth-сервис Обновляет access по refresh-токену
6 Межсервисные вызовы Проверяются токены, используется mTLS

2. HTTP-заголовки и форматы передачи токенов

Login (получение токенов)

POST /auth/login
Content-Type: application/json

{
  "username": "john.doe@example.com",
  "password": "SecurePass123!"
}

Response:
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600
}

Использование Access Token

GET /api/users/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json

Refresh Token

POST /auth/refresh
Content-Type: application/json

{
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Response:
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600
}

3. JWT (JSON Web Token) - подробное описание

Структура JWT

Header.Payload.Signature

Header (заголовок)

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload (полезная нагрузка)

{
  "sub": "user123",
  "name": "John Doe",
  "email": "john.doe@example.com",
  "roles": ["USER", "ADMIN"],
  "permissions": ["read:profile", "write:profile"],
  "iat": 1642680000,
  "exp": 1642683600,
  "iss": "https://auth.example.com",
  "aud": "api.example.com"
}

Стандартные claims

  • sub - subject (идентификатор пользователя)
  • iat - issued at (время создания)
  • exp - expiration time (время истечения)
  • iss - issuer (издатель токена)
  • aud - audience (целевая аудитория)
  • jti - JWT ID (уникальный идентификатор токена)

Signature (подпись)

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

Назначение подписи:

  • Проверка целостности данных
  • Подтверждение подлинности токена
  • Защита от подделки

4. OAuth2 - обзор и потоки

Роли участников

  • Resource Owner - владелец ресурса (пользователь)
  • Client - приложение, запрашивающее доступ
  • Authorization Server - сервер авторизации (Keycloak)
  • Resource Server - сервер ресурсов (API)

Authorization Code Flow

1. Client → Authorization Server: запрос авторизации
2. User → Authorization Server: аутентификация
3. Authorization Server → Client: authorization code
4. Client → Authorization Server: обмен code на access token
5. Client → Resource Server: запрос с access token

Client Credentials Flow

1. Client → Authorization Server: client_id + client_secret
2. Authorization Server → Client: access token
3. Client → Resource Server: запрос с access token

Implicit Flow (устаревший)

1. Client → Authorization Server: запрос авторизации
2. Authorization Server → Client: access token напрямую

Resource Owner Password Flow

1. Client → Authorization Server: username + password
2. Authorization Server → Client: access token

5. Refresh Token - как работает

Что такое Refresh Token

  • Долгоживущий токен для получения новых access token
  • Не содержит пользовательской информации
  • Может быть отозван

Где хранится

  • Frontend: HttpOnly cookie (безопасно)
  • Mobile: Secure storage (Keychain, Keystore)
  • Backend: База данных с возможностью отзыва

Процесс обновления

// Автоматическое обновление токена
async function refreshAccessToken() {
  const refreshToken = getRefreshTokenFromStorage();
  
  const response = await fetch('/auth/refresh', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      refresh_token: refreshToken
    })
  });
  
  const data = await response.json();
  saveAccessToken(data.access_token);
}

Interceptor для автоматического обновления

// Axios interceptor
axios.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401) {
      try {
        await refreshAccessToken();
        return axios.request(error.config);
      } catch (refreshError) {
        // Redirect to login
        window.location.href = '/login';
      }
    }
    return Promise.reject(error);
  }
);

6. Авторизация по ролям в Spring Security

Аннотации для проверки ролей

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping("/profile")
    @PreAuthorize("hasRole('USER')")
    public UserProfile getProfile() {
        return userService.getCurrentUserProfile();
    }
    
    @GetMapping("/admin")
    @PreAuthorize("hasRole('ADMIN')")
    public List<User> getAllUsers() {
        return userService.getAllUsers();
    }
    
    @PostMapping("/create")
    @PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')")
    public User createUser(@RequestBody User user) {
        return userService.createUser(user);
    }
    
    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN') and hasPermission(#id, 'User', 'DELETE')")
    public void deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
    }
}

Конфигурация Security

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtDecoder(jwtDecoder())
                    .jwtAuthenticationConverter(jwtAuthenticationConverter())
                )
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );
        
        return http.build();
    }
    
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            List<String> roles = jwt.getClaimAsStringList("roles");
            return roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .collect(Collectors.toList());
        });
        return converter;
    }
}

Методы авторизации

  • hasRole() - проверка роли
  • hasAnyRole() - проверка любой из ролей
  • hasAuthority() - проверка authority
  • hasPermission() - проверка разрешения (с ACL)
  • authentication.name - имя пользователя
  • isAuthenticated() - проверка аутентификации

7. Keycloak - Identity and Access Management

Что такое Keycloak

  • Open Source Identity and Access Management
  • Поддержка OAuth2, OpenID Connect, SAML
  • Centralized authentication и authorization
  • User federation (LDAP, Active Directory)
  • Social login (Google, Facebook, GitHub)

Архитектурное место Keycloak

[Frontend] → [API Gateway] → [Keycloak] → [Backend Services]
                                ↓
                          [User Database]
                          [LDAP/AD]

Основные функции

  • Authentication - проверка личности пользователя
  • Authorization - управление доступом к ресурсам
  • Single Sign-On (SSO) - единый вход в несколько приложений
  • Identity Brokering - федерация с внешними IdP
  • User Management - управление пользователями и группами

Keycloak в Docker/Kubernetes

apiVersion: apps/v1
kind: Deployment
metadata:
  name: keycloak
spec:
  replicas: 1
  selector:
    matchLabels:
      app: keycloak
  template:
    metadata:
      labels:
        app: keycloak
    spec:
      containers:

      - name: keycloak
        image: quay.io/keycloak/keycloak:latest
        env:

        - name: KEYCLOAK_ADMIN
          value: "admin"
        - name: KEYCLOAK_ADMIN_PASSWORD
          value: "admin123"
        - name: KC_DB
          value: "postgres"
        - name: KC_DB_URL
          value: "jdbc:postgresql://postgres:5432/keycloak"
        ports:

        - containerPort: 8080
        command:

        - /opt/keycloak/bin/kc.sh
        - start-dev

Интеграция с Spring Boot

@Configuration
public class KeycloakConfig {
    
    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withJwkSetUri("http://keycloak:8080/realms/my-realm/protocol/openid-connect/certs")
                .build();
    }
    
    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
            List<String> roles = (List<String>) realmAccess.get("roles");
            
            return roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                .collect(Collectors.toList());
        });
        return converter;
    }
}

Конфигурация Realm

{
  "realm": "my-app",
  "enabled": true,
  "sslRequired": "external",
  "registrationAllowed": false,
  "clients": [
    {
      "clientId": "my-frontend",
      "enabled": true,
      "publicClient": true,
      "redirectUris": ["http://localhost:3000/*"],
      "webOrigins": ["http://localhost:3000"]
    },
    {
      "clientId": "my-backend",
      "enabled": true,
      "serviceAccountsEnabled": true,
      "secret": "secret-key"
    }
  ],
  "roles": {
    "realm": [
      {"name": "USER"},
      {"name": "ADMIN"},
      {"name": "MANAGER"}
    ]
  }
}

Практические рекомендации

Безопасность

  • Используйте HTTPS для всех запросов
  • Храните refresh токены в HttpOnly cookies
  • Применяйте короткое время жизни для access токенов (15-30 минут)
  • Реализуйте logout с отзывом токенов
  • Используйте CORS правильно

Мониторинг

  • Логирование всех попыток аутентификации
  • Мониторинг неудачных попыток входа
  • Алерты на подозрительную активность
  • Метрики производительности токенов

Тестирование

  • Unit тесты для проверки ролей
  • Integration тесты для потоков OAuth2
  • Load тесты для Keycloak
  • Security тесты для уязвимостей