В этой статье рассмотрим, как реализовать аутентификацию с помощью централизованного сервера авторизации (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, используя класс UsernamePasswordAuthenticationTokenusername и 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)


  1. xeeaax
    08.08.2022 18:07
    +2

    На мой взгляд должны быть веские причины, чтобы писать сервер авторизации с нуля вместо использования чего-нибудь вроде spring-security-oauth2-authorization-server


    1. beezy92
      09.08.2022 08:58
      +1

      да и WebSecurityConfigurerAdapter deprecated.


  1. ultrinfaern
    08.08.2022 18:08
    +3

    Я так понимаю у вас здесь токен заменяе сессию, поэтому тут jwt токен явно и не нужен.

    Всем нравятся JWT, но мало кто умеет их готовить. :) В правильной реализации сервер авторизации только ВЫДАЕТ токены, не НЕ ПРОВЕРЯЕТ их. А проверяют их уже конечные серверы ресуросов без участия сервера авторизации. В этои и есть суть - что это РАСПРЕДЕЛЕННАЯ система.


    1. Sad_Bro
      08.08.2022 19:47

      Вполне правильная практика проверять таким образом, позволяет в любой момент прибить jwt токен, не ждать когда он протухнет. Просто многие ленятся так делать.


      1. Hett
        10.08.2022 00:24

        Нет, дело не в лени. У jwt токена должно быть не большое время жизни.


  1. xeeaax
    08.08.2022 22:47
    +4

    Да тут еще и API Gateway своей разработки + передача полномочий в микросервисы в заголовках. То есть любой кто сможет отправить запрос мимо API Gateway получит возможность действовать в сервисах от имени любого пользователя с любыми полномочиями.

    Ну такое себе...


    1. ris58h
      09.08.2022 13:55

      С одной стороны да, а с другой это зависит от того, насколько защищенную систему вы хотите построить. Можно и TLS на все межсервисные вызовы навесить, например.


      1. xeeaax
        09.08.2022 14:12

        Если не особо нужна защищенность, то зачем было городить проверку каждого запроса через сервис авторизации? Достаточно было классики OAuth2 - выдать JWT токет подписанный и раздать с сервиса авторизации по всем клиентам ключи. Это бы еще и отказоустойчивости добавило и latency снизило.


        1. ris58h
          09.08.2022 18:51
          +1

          К архитектуре описанной в статье у меня те же вопросы. Я лишь писал о том, что архитектура, в которой можно вызвать сервис без аутентификации внутри системы, допустима, если нет особых требований к безопасности внутри самой системы.


  1. Jasulan98
    09.08.2022 09:11

    Если у нас 100+ микросервис, насколько масштабируемо сервис permission учитывая что не все может быть на spring тем более на java? Какой best practice для permission? У каждого сервера своя бд с таблицей для проверки permission, либо как в примере request засетить изначально в api gateway?


    1. ggo
      09.08.2022 10:14

      Сильно зависит от вашего контекста.

      Академически считается правильным, что сервис самостоятельно проверяет все гранты по запросу. А у API Gateway две ответственности: отделить авторизованную зону от неавторизованной, и иногда обогатить запрос приватной информацией, которую не хочется светить в jwt-токене. Конечно, это не аксиома, и при понимании что делаешь, можно от нее отклоняться.


  1. hyragano
    09.08.2022 22:05
    +1

    По-моему очень спорное решение. С одной стороны да, у нас в таком кейсе имеется возможность управлять жизненным циклом токена (точнее прерывать его заблаговременно), можем достаточно гибко реагировать на изменения прав пользователей, чуть меньше заморачиваться насчет авторизации.

    C другой мы имеем 2 точки отказа + оверхед на походы в auth сревис (можно обмазать кэшeм, но тогда нужно решать 2-ую самую сложную проблему программирования), gateway имеет несколько отвественностей.

    ИМХО: такой подход несомненно имеет место быть, только у меня пока к нему всё же больше вопросов.