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";
}
Процесс:
- Клиент перенаправляет пользователя на Authorization Server
- Пользователь аутентифицируется и дает согласие
- Authorization Server возвращает authorization code
- Клиент обменивает 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).
Процесс:
- Клиент генерирует code_verifier и code_challenge
- В запросе авторизации отправляется code_challenge
- При обмене кода на токен отправляется code_verifier
- Сервер проверяет соответствие 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());
}
}
Лучшие практики
- Используйте HTTPS везде — OAuth 2.0 критически зависит от TLS
- Валидируйте redirect_uri — предотвращает атаки перенаправления
- Используйте state parameter — защита от CSRF атак
- Короткий TTL для authorization codes — обычно 10 минут
- Одноразовое использование authorization codes — код нельзя использовать повторно
- Храните 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:
- Client Hello — клиент предлагает версии TLS и алгоритмы шифрования
- Server Hello — сервер выбирает параметры и отправляет сертификат
- Certificate Verification — клиент проверяет подлинность сертификата
- Key Exchange — согласование ключей для симметричного шифрования
- 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-сервису
React → GET /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-токен истекает
React → POST /api/auth/refresh
(с refresh_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 тесты для уязвимостей