Поведение Spring Security по умолчанию легко использовать для стандартного веб-приложения. Он использует аутентификацию и сеансы на основе файлов cookie. Кроме того, он автоматически обрабатывает токены CSRF для вас (чтобы предотвратить атаки “человек посередине”). В большинстве случаев вам просто нужно установить права авторизации для определенных маршрутов, метод извлечения пользователя из базы данных и все.
С другой стороны, вам, вероятно, не понадобится полный сеанс, если вы создаете только API REST, который будет использоваться с внешними службами или вашим SPA/мобильным приложением. Здесь появляется JWT (веб–токен JSON) – небольшой токен с цифровой подписью. Вся необходимая информация может храниться в токене, поэтому ваш сервер может быть без сеанса.
JWT необходимо подключать к каждому HTTP-запросу, чтобы сервер мог авторизовать ваших пользователей. Есть несколько вариантов отправки токена. Например, в качестве параметра URL или в заголовке авторизации HTTP с использованием схемы носителя:
Authorization: Bearer
Веб-токен JSON содержит три основные части:
- Заголовок – обычно включает тип токена и алгоритм хэширования.
- Полезная нагрузка – обычно включает данные о пользователе и для которого выдан токен.
- Подпись – используется для проверки, не было ли сообщение изменено по пути.
Пример токена
Токен JWT из заголовка авторизации, вероятно, будет выглядеть следующим образом:
Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDI1ODksInJvbCI6WyJST0xFX1VTRVIiXX0.GzUPUWStRofrWI9Ctfv2h-XofGZwcOog9swtuqg1vSkA8kDWLcY3InVgmct7rq4ZU3lxI6CGupNgSazypHoFOA
Как вы можете видеть, есть три части, разделенные запятой – заголовок, утверждения и подпись. Заголовок и полезная нагрузка являются объектами JSON в кодировке Base64.
Заголовок:
{ "typ": "JWT", "alg": "HS512" }
Претензии/Полезная нагрузка:
{ "iss": "secure-api", "aud": "secure-app", "sub": "user", "exp": 1548242589, "rol": [ "ROLE_USER" ] }
Пример приложения
В следующем примере мы создадим простой API с 2 маршрутами – один общедоступный и один только для авторизованных пользователей.
Мы будем использовать страницу start.spring.io чтобы создать скелет нашего приложения и выбрать Безопасность и веб-зависимости. Остальные параметры зависят от ваших предпочтений.
Поддержка JWT для Java обеспечивается библиотекой JJWT поэтому нам также необходимо добавить следующие зависимости к pom.xml файл:
io.jsonwebtoken jjwt-api 0.10.5 io.jsonwebtoken jjwt-impl 0.10.5 runtime io.jsonwebtoken jjwt-jackson 0.10.5 runtime
Контроллеры
Контроллеры в наших примерах приложений будут максимально простыми. Они просто вернут сообщение или код ошибки HTTP 403 в случае, если пользователь не авторизован.
@RestController @RequestMapping("/api/public") public class PublicController { @GetMapping public String getMessage() { return "Hello from public API controller"; } }
@RestController @RequestMapping("/api/private") public class PrivateController { @GetMapping public String getMessage() { return "Hello from private API controller"; } }
Фильтры
Во-первых, мы определим некоторые повторно используемые константы и значения по умолчанию для генерации и проверки JWTS.
Примечание: Вы не должны жестко кодировать ключ подписи JWT в коде вашего приложения (мы пока проигнорируем это в примере). Вы должны использовать переменную среды или файл .properties. Кроме того, ключи должны иметь соответствующую длину. Например, алгоритму HS512 требуется ключ размером не менее 512 байт.
public final class SecurityConstants { public static final String AUTH_LOGIN_URL = "/api/authenticate"; // Signing key for HS512 algorithm // You can use the page http://www.allkeysgenerator.com/ to generate all kinds of keys public static final String JWT_SECRET = "n2r5u8x/A%D*G-KaPdSgVkYp3s6v9y$B&E(H+MbQeThWmZq4t7w!z%C*F-J@NcRf"; // JWT token defaults public static final String TOKEN_HEADER = "Authorization"; public static final String TOKEN_PREFIX = "Bearer "; public static final String TOKEN_TYPE = "JWT"; public static final String TOKEN_ISSUER = "secure-api"; public static final String TOKEN_AUDIENCE = "secure-app"; private SecurityConstants() { throw new IllegalStateException("Cannot create instance of static util class"); } }
Первый фильтр будет использоваться непосредственно для аутентификации пользователя. Он проверит параметры имени пользователя и пароля по URL-адресу и вызовет диспетчер аутентификации Spring для их проверки.
Если имя пользователя и пароль верны, то фильтр создаст токен JWT и вернет его в заголовке авторизации HTTP.
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { private final AuthenticationManager authenticationManager; public JwtAuthenticationFilter(AuthenticationManager authenticationManager) { this.authenticationManager = authenticationManager; setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) { var username = request.getParameter("username"); var password = request.getParameter("password"); var authenticationToken = new UsernamePasswordAuthenticationToken(username, password); return authenticationManager.authenticate(authenticationToken); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain, Authentication authentication) { var user = ((User) authentication.getPrincipal()); var roles = user.getAuthorities() .stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); var signingKey = SecurityConstants.JWT_SECRET.getBytes(); var token = Jwts.builder() .signWith(Keys.hmacShaKeyFor(signingKey), SignatureAlgorithm.HS512) .setHeaderParam("typ", SecurityConstants.TOKEN_TYPE) .setIssuer(SecurityConstants.TOKEN_ISSUER) .setAudience(SecurityConstants.TOKEN_AUDIENCE) .setSubject(user.getUsername()) .setExpiration(new Date(System.currentTimeMillis() + 864000000)) .claim("rol", roles) .compact(); response.addHeader(SecurityConstants.TOKEN_HEADER, SecurityConstants.TOKEN_PREFIX + token); } }
Второй фильтр обрабатывает все HTTP-запросы и проверяет, есть ли заголовок авторизации с правильным маркером. Например, если срок действия токена не истек или если ключ подписи правильный.
Если токен действителен, то фильтр добавит данные аутентификации в контекст безопасности Spring.
public class JwtAuthorizationFilter extends BasicAuthenticationFilter { private static final Logger log = LoggerFactory.getLogger(JwtAuthorizationFilter.class); public JwtAuthorizationFilter(AuthenticationManager authenticationManager) { super(authenticationManager); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { var authentication = getAuthentication(request); if (authentication == null) { filterChain.doFilter(request, response); return; } SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(request, response); } private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) { var token = request.getHeader(SecurityConstants.TOKEN_HEADER); if (StringUtils.isNotEmpty(token) && token.startsWith(SecurityConstants.TOKEN_PREFIX)) { try { var signingKey = SecurityConstants.JWT_SECRET.getBytes(); var parsedToken = Jwts.parser() .setSigningKey(signingKey) .parseClaimsJws(token.replace("Bearer ", "")); var username = parsedToken .getBody() .getSubject(); var authorities = ((List>) parsedToken.getBody() .get("rol")).stream() .map(authority -> new SimpleGrantedAuthority((String) authority)) .collect(Collectors.toList()); if (StringUtils.isNotEmpty(username)) { return new UsernamePasswordAuthenticationToken(username, null, authorities); } } catch (ExpiredJwtException exception) { log.warn("Request to parse expired JWT : {} failed : {}", token, exception.getMessage()); } catch (UnsupportedJwtException exception) { log.warn("Request to parse unsupported JWT : {} failed : {}", token, exception.getMessage()); } catch (MalformedJwtException exception) { log.warn("Request to parse invalid JWT : {} failed : {}", token, exception.getMessage()); } catch (SignatureException exception) { log.warn("Request to parse JWT with invalid signature : {} failed : {}", token, exception.getMessage()); } catch (IllegalArgumentException exception) { log.warn("Request to parse empty or null JWT : {} failed : {}", token, exception.getMessage()); } } return null; } }
Конфигурация безопасности
Последняя часть, которую нам нужно настроить, – это сама безопасность Spring. Конфигурация проста, нам нужно установить всего несколько деталей:
- Кодировщик паролей – в нашем случае bcrypt
- CORS конфигурация
- Менеджер аутентификации – в нашем случае простая аутентификация в памяти, но в реальной жизни вам понадобится что-то вроде UserDetailsService
- Укажите, какие конечные точки являются безопасными, а какие общедоступными
- Добавьте наши 2 фильтра в контекст безопасности
- Отключите управление сеансами – нам не нужны сеансы, поэтому это предотвратит создание файлов cookie сеанса
@EnableWebSecurity @EnableGlobalMethodSecurity(securedEnabled = true) public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.cors().and() .csrf().disable() .authorizeRequests() .antMatchers("/api/public").permitAll() .anyRequest().authenticated() .and() .addFilter(new JwtAuthenticationFilter(authenticationManager())) .addFilter(new JwtAuthorizationFilter(authenticationManager())) .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS); } @Override public void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("user") .password(passwordEncoder().encode("password")) .authorities("ROLE_USER"); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public CorsConfigurationSource corsConfigurationSource() { final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues()); return source; } }
Тест
Запрос к общедоступному API
GET http://localhost:8080/api/public
HTTP/1.1 200 X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Content-Type: text/plain;charset=UTF-8 Content-Length: 32 Date: Sun, 13 Jan 2019 12:22:14 GMT Hello from public API controller Response code: 200; Time: 18ms; Content length: 32 bytes
Аутентификация пользователя
POST http://localhost:8080/api/authenticate?username=user&password=password
HTTP/1.1 200 Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDYwNzUsInJvbCI6WyJST0xFX1VTRVIiXX0.yhskhWyi-PgIluYY21rL0saAG92TfTVVVgVT1afWd_NnmOMg__2kK5lcna3lXzYI4-0qi9uGpI6Ul33-b9KTnA X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Content-Length: 0 Date: Sun, 13 Jan 2019 12:21:15 GMTResponse code: 200; Time: 167ms; Content length: 0 bytes
Запрос к частному API с токеном
GET http://localhost:8080/api/private Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJzZWN1cmUtYXBpIiwiYXVkIjoic2VjdXJlLWFwcCIsInN1YiI6InVzZXIiLCJleHAiOjE1NDgyNDI1ODksInJvbCI6WyJST0xFX1VTRVIiXX0.GzUPUWStRofrWI9Ctfv2h-XofGZwcOog9swtuqg1vSkA8kDWLcY3InVgmct7rq4ZU3lxI6CGupNgSazypHoFOA
HTTP/1.1 200 X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Content-Type: text/plain;charset=UTF-8 Content-Length: 33 Date: Sun, 13 Jan 2019 12:22:48 GMT Hello from private API controller Response code: 200; Time: 12ms; Content length: 33 bytes
Запрос к частному API без токена
Вы получите сообщение HTTP 403 при вызове защищенной конечной точки без действительного JWT.
GET http://localhost:8080/api/private
HTTP/1.1 403 X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked Date: Sun, 13 Jan 2019 12:27:25 GMT { "timestamp": "2019-01-13T12:27:25.020+0000", "status": 403, "error": "Forbidden", "message": "Access Denied", "path": "/api/private" } Response code: 403; Time: 28ms; Content length: 125 bytes
Вывод
Цель этой статьи не в том, чтобы показать единственно правильный способ использования JWTS в Spring Security. Это пример того, как вы можете сделать это в своем реальном приложении. Кроме того, я не хотел слишком углубляться в эту тему, поэтому не хватает таких вещей, как обновление токенов, аннулирование и т.д. но я, вероятно, затрону эти темы в будущем.
tl;dr Вы можете найти полный исходный код этого примера API в моем репозитории GitHub .
Оригинал: “https://dev.to/kubadlo/spring-security-with-jwt-3j76”