Сервер авторизации для микросервисов на Spring Boot
Что такое JWT-токен и зачем его использовать?
JSON Web Token (JWT) — это стандарт передачи информации с полезной нагрузкой в формате JSON в виде некоторого количества утверждений (claim) с опциональной подписью и/или шифрованием.
JWT-токены содержат сведения для аутентификации и могут использоваться в нескольких сервисах, инстансах для реализации stateless-аутентификации (без сохранения состояния). При использовании JWT-токенов нет необходимости содержать отдельные ресурсы для пользовательских сессий или хранить токены/сессии в отдельной базе данных/кэше.
Архитектура решения
Отметим основные моменты:
- Для создания и проверки JWT-токенов используется централизованный сервер авторизации (Authorization Server).
- API Gateway представляет собой единую точку входа в приложение, которая перенаправляет запросы к соответствующим микросервисам.
- К маршрутам (routes) добавляется Gateway-фильтр, проверяющий JWT-токены в запросах к защищаемым ресурсам. Для валидации токена и получения имени пользователя с его полномочиями выполняется обращение к серверу авторизации (Authorization Server). Далее полученная информация передается другим сервисам в заголовке запроса.
- Для Service Discovery (обнаружения сервисов) будем использовать Eureka Discovery Client.
Аутентификация
Аутентификация происходит следующим образом:
- Пользователь логинится (создает токен аутентификации), вызывая конечную точку
/login
(POST) с передачей имени пользователя и пароля. В ответ в заголовке он получает Bearer-токен. - Токен передается в заголовке в параметре
Authorization
в форматеBearer access_token
. - Для запросов к защищенным ресурсам вызывается кастомный Gateway Filter (AuthenticationPrefilter). В фильтре выполняется обращение к конечной точке /api/v1/validateToken сервиса аутентификации (Authentication Service), который валидирует токен и, в случае успешной проверки, отправляет в ответ имя пользователя и его полномочия (authorities).
- Если токен валидный, то перед переадресацией на ресурс, запрошенный пользователем, к заголовку запроса добавляются имя пользователя и полномочия.
- В остальных микросервисах (например, user-service) фильтр авторизации, наследуемый от
OncePerRequestFilter
, создает объектAuthentication
, используя классUsernamePasswordAuthenticationToken
(с username и SimpleGrantedAuthority из заголовка, с паролем null). - Если у пользователя есть полномочия/доступ к ресурсу, то запрос разрешается. В противном случае клиенту возвращается ответ 401 Unathorized / 403 Forbidden.
Пишем сервисы
Eureka Server
- Создайте приложение Spring Boot, через Spring Initializr с зависимостью
spring-cloud-starter-netflix-eureka-server
. Также добавьтеspring-cloud-dependencies
вdependencyManagement
. - Теперь для запуска Eureka Server достаточно добавить аннотацию @EnableEurekaServer к основному классу приложения.
- В property-файл добавьте следующие настройки Eureka Server:
spring.application.name=naming-server server.port=8761 eureka.client.register-with-eureka=false eureka.client.fetch-registry=false eureka.instance.prefer-ip-address=true
- Eureka Server будет доступен по адресу http://localhost:8761/. На главной странице можно увидеть список зарегистрированных сервисов.
Authorization Service (сервис авторизации)
- Создайте приложения Spring Boot со следующими зависимостями:
spring-boot-starter-security
,spring-boot-starter-web
,spring-cloud-starter-sleuth
,spring-cloud-starter-config
,spring-cloud-starter-netflix-eureka-client
,spring-boot-starter-data-jpa
,spring-boot-starter-data-mongodb
,spring-boot-starter-data-redis
иlombok
. - Зависимость
spring-boot-starter-security
необходима для авторизации и аутентификации,spring-boot-starter-data-mongodb
иspring-boot-starter-data-jpa
— для доступа к учетным данным в MongoDB. Для создания и проверки JWT-токенов будем использоватьio.jsonwebtoken:jjwt
. - Для аутентификации с использованием учетных данных в базе данных напишем свою реализацию
UserDetailsService
из Spring Security. Для получения учетных данных пользователя из базы данных и создания экземпляраUserDetails
необходимо реализовать методloadUserByUsername()
.
@Service public class ApplicationUserDetailsService implements UserDetailsService { @Autowired private UsersService usersService; @Override public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { return new ApplicationUsers(usersService.getByUsrName(s).orElseThrow(() -> new UsernameNotFoundException("Username Not Found"))); } }
- Своя реализация
UserDetails
нам нужна для маппинга объектов, хранящихся в базе данных, на объекты, требуемые Spring Security. - Создаем класс конфигурации — наследник
WebSecurityConfigurerAdapter
.
package com.infotrends.in.authenticationserver.security.config; import com.infotrends.in.authenticationserver.security.filters.JWTAuthenticationFilter; import com.infotrends.in.authenticationserver.security.filters.JWTVerifierFilter; import com.infotrends.in.authenticationserver.security.services.ApplicationUserDetailsService; import com.infotrends.in.authenticationserver.services.redis.TokensRedisService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private PasswordEncoder encoder; @Autowired private ApplicationUserDetailsService applicationUserDetailsService; @Autowired private TokensRedisService redisService; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .addFilter(new JWTAuthenticationFilter(authenticationManager(), redisService)) .addFilterAfter(new JWTVerifierFilter(redisService), JWTAuthenticationFilter.class) .authorizeRequests() .antMatchers("/api/v1/validateConnection/whitelisted").permitAll() .anyRequest() .authenticated() .and().httpBasic(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(authenticationProvider()); } @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); authenticationProvider.setPasswordEncoder(encoder); authenticationProvider.setUserDetailsService(applicationUserDetailsService); return authenticationProvider; } }
- Здесь мы создаем бин
DaoAuthenticationProvider
с кодировщиком паролей и нашей реализациейUserDetailsService
. - Которые, в свою очередь, используются для переопределения
configure(AuthenticationManagerBuilder auth)
, настраивающегоAuthenticationManagerBuilder
для использования созданного нами Authentication Provider. - Далее для использования JWT/Bearer-токенов вместо аутентификации по логину и паролю, надо настроить два фильтра: один для генерации Bearer-токена, а другой — для его проверки.
- Фильтр генерации JWT создаем как подкласс
UsernamePasswordAuthenticationFilter
. Для проверки учетных данных переопределяем методattemptAuthentication()
. Для создания JWT-токена при успешной аутентификации — метод иsuccessAuthentication
().
package com.infotrends.in.authenticationserver.security.filters; import com.fasterxml.jackson.databind.ObjectMapper; import com.infotrends.in.InfoTrendsIn.security.SecurityConstants; import com.infotrends.in.authenticationserver.model.ConnValidationResponse; import com.infotrends.in.authenticationserver.model.JwtAuthenticationModel; import com.infotrends.in.authenticationserver.model.redis.TokensEntity; import com.infotrends.in.authenticationserver.services.redis.TokensRedisService; import com.infotrends.in.authenticationserver.utils.Utilities; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.Date; @Slf4j @RequiredArgsConstructor public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter { private final AuthenticationManager authenticationManager; private ObjectMapper mapper=new ObjectMapper(); private final TokensRedisService tokensRedisService; @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { try { JwtAuthenticationModel authModel = mapper.readValue(request.getInputStream(), JwtAuthenticationModel.class); Authentication authentication = new UsernamePasswordAuthenticationToken(authModel.getUsername(), authModel.getPassword()); return authenticationManager.authenticate(authentication); } catch (IOException e) { throw new RuntimeException(e); } } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { String token = Jwts.builder() .setSubject(authResult.getName()) .claim("authorities", authResult.getAuthorities()) .claim("principal", authResult.getPrincipal()) .setIssuedAt(new Date()) .setIssuer(SecurityConstants.ISSUER) .setExpiration(Date.from(LocalDateTime.now().plusMinutes(30).toInstant(ZoneOffset.UTC))) .signWith(SignatureAlgorithm.HS256, SecurityConstants.KEY) .compact(); log.info(token); TokensEntity tokensEntity = TokensEntity.builder().id(Utilities.generateUuid()).authenticationToken(token) .username(authResult.getName()) .createdBy("SYSTEM").createdOn(LocalDateTime.now()) .modifiedBy("SYSTEM").modifiedOn(LocalDateTime.now()) .build(); tokensEntity = tokensRedisService.save(tokensEntity); response.addHeader(SecurityConstants.HEADER, String.format("Bearer %s", tokensEntity.getId())); // response.addHeader("Expiration", String.valueOf(30*60)); ConnValidationResponse respModel = ConnValidationResponse.builder().isAuthenticated(true).build(); response.addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); response.getOutputStream().write(mapper.writeValueAsBytes(respModel)); } }
- Фильтр проверки JWT наследуем от
OncePerRequestFilter
и настраиваем его вызов после фильтра, генерирующего JWT, с помощьюaddFilterAfter()
в классе конфигурацииWebSecurityConfig
.
package com.infotrends.in.authenticationserver.security.filters; import com.infotrends.in.InfoTrendsIn.security.SecurityConstants; import com.infotrends.in.authenticationserver.model.redis.TokensEntity; import com.infotrends.in.authenticationserver.services.redis.TokensRedisService; import com.infotrends.in.authenticationserver.utils.Utilities; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import lombok.RequiredArgsConstructor; import org.apache.tomcat.util.http.parser.Authorization; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @RequiredArgsConstructor public class JWTVerifierFilter extends OncePerRequestFilter { private final TokensRedisService tokensRedisService; @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { String bearerToken = httpServletRequest.getHeader(SecurityConstants.HEADER); if(!(Utilities.validString(bearerToken) && bearerToken.startsWith(SecurityConstants.PREFIX))) { filterChain.doFilter(httpServletRequest, httpServletResponse); return; } String authToken = bearerToken.replace(SecurityConstants.PREFIX, ""); Optional<TokensEntity> tokensEntity = tokensRedisService.findById(authToken); if(!tokensEntity.isPresent()) { filterChain.doFilter(httpServletRequest, httpServletResponse); return; } String token = tokensEntity.get().getAuthenticationToken(); Jws<Claims> authClaim = Jwts.parser().setSigningKey(SecurityConstants.KEY) .requireIssuer(SecurityConstants.ISSUER) .parseClaimsJws(token); String username = authClaim.getBody().getSubject(); List<Map<String, String>> authorities = (List<Map<String, String>>) authClaim.getBody().get("authorities"); List<GrantedAuthority> grantedAuthorities = authorities.stream().map(map -> new SimpleGrantedAuthority(map.get("authority"))) .collect(Collectors.toList()); Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, grantedAuthorities); SecurityContextHolder.getContext().setAuthentication(authentication); httpServletRequest.setAttribute("username", username); httpServletRequest.setAttribute("authorities", grantedAuthorities); filterChain.doFilter(httpServletRequest, httpServletResponse); } } package com.infotrends.in.authenticationserver.resources; import com.sun.security.auth.UserPrincipal; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.GrantedAuthority; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; import java.util.List; @RestController @RequestMapping("/api/v1/validateToken") public class ConnectionValidatorResource { @GetMapping(value = "", produces = {MediaType.APPLICATION_JSON_VALUE}) public ResponseEntity<ConnValidationResponse> validateGet(HttpServletRequest request) { String username = (String) request.getAttribute("username"); List<GrantedAuthority> grantedAuthorities = (List<GrantedAuthority>) request.getAttribute("authorities"); return ResponseEntity.ok(ConnValidationResponse.builder().status("OK").methodType(HttpMethod.GET.name()) .username(username).authorities(grantedAuthorities) .isAuthenticated(true).build()); } @Getter @Builder @ToString public class ConnValidationResponse { private String status; private boolean isAuthenticated; private String methodType; private String username; private List<GrantedAuthority> authorities; } }
Дополнительная функциональность
Как правило, содержимое JWT-токена нельзя изменить после его создания. Однако токен можно легко расшифровать и прочитать.
Но мы можем вместо отправки пользователю JWT-токена, содержащего все данные, вернуть только случайный UUID, сгенерированный для данного запроса аутентификации, а необходимые данные сохранить в кэше Redis.
Таким образом, клиентскому приложению будет возвращен только сгенерированный UUID, а JWT-токен будет использоваться другими сервисами для авторизации/аутентификации.
package com.infotrends.in.authenticationserver.model.redis; import lombok.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisHash; import java.time.LocalDateTime; @RedisHash(value = "Tokens", timeToLive = 86400) @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor public class TokensEntity { private String id; private String username; private String authenticationToken; private String modifiedBy; private LocalDateTime modifiedOn; private String createdBy; private LocalDateTime createdOn; }
API Gateway
- Создайте приложение Spring Boot со следующими зависимостями, необходимыми для API Gateway с Eureka Client:
spring-cloud-starter-gateway
,spring-cloud-starter-config
иspring-cloud-starter-netflix-eureka-client
. - Файл настроек Cloud Config Server и Eureka Server:
debug: true logging: level: org.springframework.cloud.gateway: DEBUG reactor.netty.http.client: DEBUG server: port: '8765' spring: cloud: config: profile: dev gateway: discovery.locator.enabled: true config: import: optional:configserver:http://clouduser:configserver705!@localhost:8888 application: name: api-gateway jackson: date-format: yyyy-MM-dd HH:mm:ss management: endpoints: web: exposure: include: '*' eureka: client: serviceUrl: defaultZone: http://eurekauser:eureka124!@localhost:8761/eureka instance: prefer-ip-address: 'true'
- Далее добавляем к классу
ApiGatewayApplication
аннотацию@EnableFeignClients
для написания запросов к Eureka Server. - И настраиваем Gateway Filter, который проверяет Bearer-токен в запросах, используя конечную точку
/validateToken
на сервере авторизации. Для этого наследуемся от классаAbstractGatewayFilterFactory
, предоставленного Spring-API Gateway, и переопределяем методapply(Config config)
, который возвращает реализациюGatewayFilter
.
package com.infotrends.in.InfoTrendsIn.ApiGateway.filters; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.infotrends.in.InfoTrendsIn.ApiGateway.model.Authorities; import com.infotrends.in.InfoTrendsIn.ApiGateway.model.ConnValidationResponse; import com.infotrends.in.InfoTrendsIn.ApiGateway.utils.Utilities; import com.infotrends.in.InfoTrendsIn.exceptions.model.ExceptionResponseModel; import com.infotrends.in.InfoTrendsIn.security.SecurityConstants; import lombok.AllArgsConstructor; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import java.util.Date; import java.util.List; import java.util.function.Predicate; @Component @Slf4j public class AuthenticationPrefilter extends AbstractGatewayFilterFactory<AuthenticationPrefilter.Config> { @Autowired @Qualifier("excludedUrls") List<String> excludedUrls; private final WebClient.Builder webClientBuilder; public AuthenticationPrefilter(WebClient.Builder webClientBuilder) { super(Config.class); this.webClientBuilder=webClientBuilder; } @Autowired private ObjectMapper objectMapper; @Override public GatewayFilter apply(Config config) { return (exchange, chain) -> { ServerHttpRequest request = exchange.getRequest(); log.info("**************************************************************************"); log.info("URL is - " + request.getURI().getPath()); String bearerToken = request.getHeaders().getFirst(SecurityConstants.HEADER); log.info("Bearer Token: "+ bearerToken); if(isSecured.test(request)) { return webClientBuilder.build().get() .uri("lb://authentication-service/api/v1/validateToken") .header(SecurityConstants.HEADER, bearerToken) .retrieve().bodyToMono(ConnValidationResponse.class) .map(response -> { exchange.getRequest().mutate().header("username", response.getUsername()); exchange.getRequest().mutate().header("authorities", response.getAuthorities().stream().map(Authorities::getAuthority).reduce("", (a, b) -> a + "," + b)); return exchange; }).flatMap(chain::filter).onErrorResume(error -> { log.info("Error Happened"); HttpStatus errorCode = null; String errorMsg = ""; if (error instanceof WebClientResponseException) { WebClientResponseException webCLientException = (WebClientResponseException) error; errorCode = webCLientException.getStatusCode(); errorMsg = webCLientException.getStatusText(); } else { errorCode = HttpStatus.BAD_GATEWAY; errorMsg = HttpStatus.BAD_GATEWAY.getReasonPhrase(); } // AuthorizationFilter.AUTH_FAILED_CODE return onError(exchange, String.valueOf(errorCode.value()) ,errorMsg, "JWT Authentication Failed", errorCode); }); } return chain.filter(exchange); }; } public Predicate<ServerHttpRequest> isSecured = request -> excludedUrls.stream().noneMatch(uri -> request.getURI().getPath().contains(uri)); private Mono<Void> onError(ServerWebExchange exchange, String errCode, String err, String errDetails, HttpStatus httpStatus) { DataBufferFactory dataBufferFactory = exchange.getResponse().bufferFactory(); // ObjectMapper objMapper = new ObjectMapper(); ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(httpStatus); try { response.getHeaders().add("Content-Type", "application/json"); ExceptionResponseModel data = new ExceptionResponseModel(errCode, err, errDetails, null, new Date()); byte[] byteData = objectMapper.writeValueAsBytes(data); return response.writeWith(Mono.just(byteData).map(t -> dataBufferFactory.wrap(t))); } catch (JsonProcessingException e) { e.printStackTrace(); } return response.setComplete(); } @NoArgsConstructor public static class Config { } }
Маршруты
В отдельной конфигурации настраиваем маршруты (routes) на использование созданного выше GatewayFilter
.
package com.infotrends.in.InfoTrendsIn.ApiGateway.config; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import com.infotrends.in.InfoTrendsIn.ApiGateway.filters.AuthenticationPrefilter; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.cloud.gateway.route.RouteLocator; import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.text.SimpleDateFormat; import java.time.format.DateTimeFormatter; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; @Configuration public class RouteConfiguration { @Bean public RouteLocator routes( RouteLocatorBuilder builder, AuthenticationPrefilter authFilter) { return builder.routes() .route("auth-service-route", r -> r.path("/authentication-service/**") .filters(f -> f.rewritePath("/authentication-service(?<segment>/?.*)", "$\\{segment}") .filter(authFilter.apply( new AuthenticationPrefilter.Config()))) .uri("lb://authentication-service")) .route("user-service-route", r -> r.path("/user-service/**") .filters(f -> f.rewritePath("/user-service(?<segment>/?.*)", "$\\{segment}") .filter(authFilter.apply( new AuthenticationPrefilter.Config()))) .uri("lb://user-service")) .build(); } }
User-Service
- Создайте проект User-Service с помощью Spring Initializr. В этом сервисе будут храниться пользователи. Это будет пример бэкенда.
- Создайте класс конфигурации, наследуя класс
WebSecurityConfigurerAdapter
, и переопределите методvoid configure(HttpSecurity http)
. Здесь мы настроим запуск нашего фильтра проверки токена (JWTVerifierFilter) передUsernamePasswordAuthenticationFilter
.
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter{ @Autowired private PasswordEncoder encoder; @Value("${security.users.username}") private String username; @Value("${security.users.password}") private String password; @Autowired private AppUserDetailsService appUserDetailsService; @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .headers().frameOptions().disable() .and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .addFilterBefore(new JWTVerifierFilter(), UsernamePasswordAuthenticationFilter.class) .authorizeRequests() .antMatchers(HttpMethod.GET, "/api/v1/users").permitAll() .anyRequest() .authenticated() .and().httpBasic(); } }
JWTVerifierFilter
проверяет наличие в заголовке запроса данных об имени пользователя и полномочиях, и создает объект Authentication, используя классUsernamePasswordAuthenticationToken
.- Далее, используя
SecurityContextHolder
, сохраняет authentication в контекст безопасности Spring Security.
package com.infotrends.in.InfoTrendsIn.config.security.filters; import com.infotrends.in.InfoTrendsIn.security.SecurityConstants; import com.infotrends.in.InfoTrendsIn.utils.Utilities; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.*; import java.util.stream.Collectors; public class JWTVerifierFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { String authHeader = httpServletRequest.getHeader("Authorization"); if(!Utilities.validString(authHeader) || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(httpServletRequest, httpServletResponse); return; } logHeaders(httpServletRequest); String username=httpServletRequest.getHeader("username"); List<Map<String, String>> authorities = new ArrayList<>(); String authoritiesStr = httpServletRequest.getHeader("authorities"); Set<SimpleGrantedAuthority> simpleGrantedAuthorities = new HashSet<>(); if(Utilities.validString(authoritiesStr)) { simpleGrantedAuthorities=Arrays.stream(authoritiesStr.split(",")).distinct() .filter(Utilities::validString).map(SimpleGrantedAuthority::new).collect(Collectors.toSet());; } Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, simpleGrantedAuthorities); SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(httpServletRequest, httpServletResponse); } private void logHeaders(HttpServletRequest httpServletRequest) { Enumeration<String> headerNames = httpServletRequest.getHeaderNames(); while(headerNames.hasMoreElements()) { String header=headerNames.nextElement(); logger.info(String.format("Header: %s --- Value: %s", header, httpServletRequest.getHeader(header))); } } }
Таким образом, запрос к ресурсу / конечной точке разрешается, если запрашивающий их пользователь имеет соответствующий доступ/полномочия (authority). Ниже приведен пример для GET-запроса.
@PreAuthorize("hasAnyAuthority('USER_READ', 'USER')") @GetMapping(value = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE}) public ResponseEntity<EntityModel<UsersResponseModel>> getUserById(@PathVariable("id") String id) { UsersResponseModel respModel = new UsersResponseModel(); Optional<Users> user = usersSvc.findById(id); if(!user.isPresent()) { throw new UserExceptions.UserNotFoudException(ErrorsMappings.USER_NOT_FOUND_MESSAGE); } respModel.setUser(user.get()); respModel.setCode(HttpStatus.OK.value()); EntityModel<UsersResponseModel> entity = EntityModel.of(respModel); entity = usersProcess.generateHateoas(entity, this, "view-user", user.get().getId()); return new ResponseEntity(entity, HttpStatus.OK); }
Используемые зависимости:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-config</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency>
Для использования Config Server и Eureka Client также добавим следующие зависимости в раздел dependencyManagement.
<properties> <spring-cloud.version>2020.0.3</spring-cloud.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
Использование JWT-токена
Проверка запросов к микросервисам
- API для аутентификации пользователя и генерации JWT-токена:
- API validateToken, используемый для валидации токена, отправляемого в запросе, и получения сведений об авторизации. (Этот API позже будет заблокирован от внешнего доступа).
- Запрос к защищенному ресурсу в User-Service при вызове через API Gateway с валидным токеном авторизации:
- Запрос к защищенному ресурсу в User-Service при вызове через API Gateway без валидного токена:
- Пример JWT-токена, созданный сервисом авторизации:
Полный исходный код проекта вы можете найти по адресу https://github.com/Vicky-cmd/Authentication-Service.git