В этой статье рассмотрим, как реализовать аутентификацию с помощью централизованного сервера авторизации (Authorization Server) и API-шлюза (API Gateway).
Что такое 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
Сегодня в 20:00 состоится открытое занятие «Структура программы на Java. Примитивные типы». На этом открытом уроке вы сможете познакомиться с основными этапами создания простейшей программы на Java, понять принципы работы компилятора и виртуальной машины, а также разобраться с class-файлами. На уроке мы изучим примитивные типы данных, константы и enum. Регистрация доступна по ссылке для всех желающих.
Комментарии (12)
ultrinfaern
08.08.2022 18:08+3Я так понимаю у вас здесь токен заменяе сессию, поэтому тут jwt токен явно и не нужен.
Всем нравятся JWT, но мало кто умеет их готовить. :) В правильной реализации сервер авторизации только ВЫДАЕТ токены, не НЕ ПРОВЕРЯЕТ их. А проверяют их уже конечные серверы ресуросов без участия сервера авторизации. В этои и есть суть - что это РАСПРЕДЕЛЕННАЯ система.
xeeaax
08.08.2022 22:47+4Да тут еще и API Gateway своей разработки + передача полномочий в микросервисы в заголовках. То есть любой кто сможет отправить запрос мимо API Gateway получит возможность действовать в сервисах от имени любого пользователя с любыми полномочиями.
Ну такое себе...
ris58h
09.08.2022 13:55С одной стороны да, а с другой это зависит от того, насколько защищенную систему вы хотите построить. Можно и TLS на все межсервисные вызовы навесить, например.
xeeaax
09.08.2022 14:12Если не особо нужна защищенность, то зачем было городить проверку каждого запроса через сервис авторизации? Достаточно было классики OAuth2 - выдать JWT токет подписанный и раздать с сервиса авторизации по всем клиентам ключи. Это бы еще и отказоустойчивости добавило и latency снизило.
ris58h
09.08.2022 18:51+1К архитектуре описанной в статье у меня те же вопросы. Я лишь писал о том, что архитектура, в которой можно вызвать сервис без аутентификации внутри системы, допустима, если нет особых требований к безопасности внутри самой системы.
Jasulan98
09.08.2022 09:11Если у нас 100+ микросервис, насколько масштабируемо сервис permission учитывая что не все может быть на spring тем более на java? Какой best practice для permission? У каждого сервера своя бд с таблицей для проверки permission, либо как в примере request засетить изначально в api gateway?
ggo
09.08.2022 10:14Сильно зависит от вашего контекста.
Академически считается правильным, что сервис самостоятельно проверяет все гранты по запросу. А у API Gateway две ответственности: отделить авторизованную зону от неавторизованной, и иногда обогатить запрос приватной информацией, которую не хочется светить в jwt-токене. Конечно, это не аксиома, и при понимании что делаешь, можно от нее отклоняться.
hyragano
09.08.2022 22:05+1По-моему очень спорное решение. С одной стороны да, у нас в таком кейсе имеется возможность управлять жизненным циклом токена (точнее прерывать его заблаговременно), можем достаточно гибко реагировать на изменения прав пользователей, чуть меньше заморачиваться насчет авторизации.
C другой мы имеем 2 точки отказа + оверхед на походы в auth сревис (можно обмазать кэшeм, но тогда нужно решать 2-ую самую сложную проблему программирования), gateway имеет несколько отвественностей.
ИМХО: такой подход несомненно имеет место быть, только у меня пока к нему всё же больше вопросов.
xeeaax
На мой взгляд должны быть веские причины, чтобы писать сервер авторизации с нуля вместо использования чего-нибудь вроде spring-security-oauth2-authorization-server
beezy92
да и
WebSecurityConfigurerAdapter
deprecated.