API REST usando Spring Security y JWT

Tarde o temprano, todos los desarrolladores de Java se enfrentarán a la necesidad de implementar una aplicación API REST segura. En este artículo quiero compartir mi implementación de esta tarea.





1. ¿Qué es DESCANSO?

REST (del inglés. Representational State Transfer) son los principios generales de la organización de la interacción de una aplicación / sitio con un servidor utilizando el protocolo HTTP.





El siguiente diagrama muestra el modelo general.





Toda la interacción con el servidor se reduce a 4 operaciones (4 es un mínimo necesario y suficiente, en una implementación específica puede haber más tipos de operaciones):





  1. ( JSON, XML);





  2. ;





  3. ;









, REST .





2.

REST , . , . .

:





3.

Spring Boot Spring Web, :





  1. Java 8+;





  2. Apache Maven





Spring Security JsonWebToken (JWT).

Lombok.





4.

. Spring Boot REST API .





4.1 Web-

Maven- SpringBootSecurityRest. , Intellij IDEA, Spring Boot DevTools, Lombok Spring Web, pom-.





4.2 pom-xml

pom- :





  1. parent- spring-boot-starter-parent;





  2. spring-boot-starter-web, spring-boot-devtools Lombok.





<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.5.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com</groupId>
    <artifactId>springbootsecurityrest</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>springbootsecurityrest</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>15</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!--Test-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>
      
      



4.3 REST

, com.springbootsecurityrest :





  • model – POJO-;





  • repository – , .. , ;





  • service – , , , ( );





  • rest – .





model POJO User.





import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class User {
    private String login;
    private String password;
    private String firstname;
    private String lastname;
    private Integer age;
}
      
      



repository UserRepository c :





  1. getByLogin – ;





  2. getAll – . Spring , @Repository.





@Repository
public class UserRepository {
  
    private List<User> users;

    public UserRepository() {
        this.users = List.of(
                new User("anton", "1234", "", "", 20),
                new User("ivan", "12345", "", "", 21));
    }

    public User getByLogin(String login) {
        return this.users.stream()
                .filter(user -> login.equals(user.getLogin()))
                .findFirst()
                .orElse(null);
    }

    public List<User> getAll() {
        return this.users;
    }
      
      



service UserService. @Service UserRepository. getAll, getByLogin .





@Service
public class UserService {

    private UserRepository repository;

    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public List<User> getAll() {
        return this.repository.getAll();
    }

    public User getByLogin(String login) {
        return this.repository.getByLogin(login);
    }
}
      
      



UserController rest, UserService getAll. @GetMapping , .





@RestController
public class UserController {

    private UserService service;

    public UserController(UserService service) {
        this.service = service;
    }

    @GetMapping(path = "/users", produces = MediaType.APPLICATION_JSON_VALUE)
    public @ResponseBody List<User> getAll() {
        return this.service.getAll();
    }
}
      
      



, , http://localhost:8080/users, , :





5. Spring Security

REST API . , , . Spring Security JWT.





Spring Security Java/JavaEE framework, , , Spring Framework.





JSON Web Token (JWT) — (RFC 7519) , JSON. , - . , , .





5.1

pom-.





<!--Security-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
<dependency>
    <groupId>jakarta.xml.bind</groupId>
    <artifactId>jakarta.xml.bind-api</artifactId>
    <version>2.3.3</version>
</dependency>
      
      



5.2

, security JwtTokenRepository CsrfTokenRepository ( org.springframework.security.web.csrf).





:





  1. generateToken;





  2. – saveToken;





  3. – loadToken.





Jwt, .





@Repository
public class JwtTokenRepository implements CsrfTokenRepository {

    @Getter
    private String secret;

    public JwtTokenRepository() {
        this.secret = "springrest";
    }

    @Override
    public CsrfToken generateToken(HttpServletRequest httpServletRequest) {
        String id = UUID.randomUUID().toString().replace("-", "");
        Date now = new Date();
        Date exp = Date.from(LocalDateTime.now().plusMinutes(30)
                .atZone(ZoneId.systemDefault()).toInstant());

        String token = "";
        try {
            token = Jwts.builder()
                    .setId(id)
                    .setIssuedAt(now)
                    .setNotBefore(now)
                    .setExpiration(exp)
                    .signWith(SignatureAlgorithm.HS256, secret)
                    .compact();
        } catch (JwtException e) {
            e.printStackTrace();
            //ignore
        }
        return new DefaultCsrfToken("x-csrf-token", "_csrf", token);
    }

    @Override
    public void saveToken(CsrfToken csrfToken, HttpServletRequest request, HttpServletResponse response) {
    }

    @Override
    public CsrfToken loadToken(HttpServletRequest request) {
        return null;
    }
}
      
      



secret , , , , ip- . exp , 30 . application.properties.





30 . . , 30 .





    @Override
    public void saveToken(CsrfToken csrfToken, HttpServletRequest request, HttpServletResponse response) {
        if (Objects.nonNull(csrfToken)) {
            if (!response.getHeaderNames().contains(ACCESS_CONTROL_EXPOSE_HEADERS))
                response.addHeader(ACCESS_CONTROL_EXPOSE_HEADERS, csrfToken.getHeaderName());

            if (response.getHeaderNames().contains(csrfToken.getHeaderName()))
                response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());
            else
                response.addHeader(csrfToken.getHeaderName(), csrfToken.getToken());
        }
    }

    @Override
    public CsrfToken loadToken(HttpServletRequest request) {
        return (CsrfToken) request.getAttribute(CsrfToken.class.getName());
    }
      
      



response ( ) headers Access-Control-Expose-Headers.





response, .





    public void clearToken(HttpServletResponse response) {
        if (response.getHeaderNames().contains("x-csrf-token"))
            response.setHeader("x-csrf-token", "");
    }
      
      



5.3 SpringSecurity

JwtCsrfFilter, OncePerRequestFilter ( org.springframework.web.filter). . ( /auth/login), .





public class JwtCsrfFilter extends OncePerRequestFilter {

    private final CsrfTokenRepository tokenRepository;

    private final HandlerExceptionResolver resolver;

    public JwtCsrfFilter(CsrfTokenRepository tokenRepository, HandlerExceptionResolver resolver) {
        this.tokenRepository = tokenRepository;
        this.resolver = resolver;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        request.setAttribute(HttpServletResponse.class.getName(), response);
        CsrfToken csrfToken = this.tokenRepository.loadToken(request);
        boolean missingToken = csrfToken == null;
        if (missingToken) {
            csrfToken = this.tokenRepository.generateToken(request);
            this.tokenRepository.saveToken(csrfToken, request, response);
        }

        request.setAttribute(CsrfToken.class.getName(), csrfToken);
        request.setAttribute(csrfToken.getParameterName(), csrfToken);
        if (request.getServletPath().equals("/auth/login")) {
            try {
                filterChain.doFilter(request, response);
            } catch (Exception e) {
                resolver.resolveException(request, response, null, new MissingCsrfTokenException(csrfToken.getToken()));
            }
        } else {
            String actualToken = request.getHeader(csrfToken.getHeaderName());
            if (actualToken == null) {
                actualToken = request.getParameter(csrfToken.getParameterName());
            }
            try {
                if (!StringUtils.isEmpty(actualToken)) {
                    Jwts.parser()
                            .setSigningKey(((JwtTokenRepository) tokenRepository).getSecret())
                            .parseClaimsJws(actualToken);

                        filterChain.doFilter(request, response);
                } else
                    resolver.resolveException(request, response, null, new InvalidCsrfTokenException(csrfToken, actualToken));
            } catch (JwtException e) {
                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request));
                }

                if (missingToken) {
                    resolver.resolveException(request, response, null, new MissingCsrfTokenException(actualToken));
                } else {
                    resolver.resolveException(request, response, null, new InvalidCsrfTokenException(csrfToken, actualToken));
                }
            }
        }
    }
}
      
      



5.4

, . UserService UserDetailsService org.springframework.security.core.userdetails. , .





@Service
public class UserService implements UserDetailsService {

    private UserRepository repository;

    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public List<User> getAll() {
        return this.repository.getAll();
    }

    public User getByLogin(String login) {
        return this.repository.getByLogin(login);
    }

    @Override
    public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
        User u = getByLogin(login);
        if (Objects.isNull(u)) {
            throw new UsernameNotFoundException(String.format("User %s is not found", login));
        }
        return new org.springframework.security.core.userdetails.User(u.getLogin(), u.getPassword(), true, true, true, true, new HashSet<>());
    }
}
      
      



UserDetails org.springframework.security.core.userdetails. GrantedAuthority, , , . , UsernameNotFoundException.





5.5

. AuthController getAuthUser. /auth/login, Security , UserService .





@RestController
@RequestMapping("/auth")
public class AuthController {

    private UserService service;

    public AuthController(UserService service) {
        this.service = service;
    }

    @PostMapping(path = "/login", produces = MediaType.APPLICATION_JSON_VALUE)
    public @ResponseBody com.springbootsecurityrest.model.User getAuthUser() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null) {
            return null;
        }
        Object principal = auth.getPrincipal();
        User user = (principal instanceof User) ? (User) principal : null;
        return Objects.nonNull(user) ? this.service.getByLogin(user.getUsername()) : null;
    }

}
      
      



5.6

, . GlobalExceptionHandler com.springbootsecurityrest, ResponseEntityExceptionHandler handleAuthenticationException.





401 (UNAUTHORIZED) ErrorInfo.





@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private JwtTokenRepository tokenRepository;

    public GlobalExceptionHandler(JwtTokenRepository tokenRepository) {
        this.tokenRepository = tokenRepository;
    }

    @ExceptionHandler({AuthenticationException.class, MissingCsrfTokenException.class, InvalidCsrfTokenException.class, SessionAuthenticationException.class})
    public ErrorInfo handleAuthenticationException(RuntimeException ex, HttpServletRequest request, HttpServletResponse response){
        this.tokenRepository.clearToken(response);
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        return new ErrorInfo(UrlUtils.buildFullRequestUrl(request), "error.authorization");
    }

    @Getter public class ErrorInfo {
        private final String url;
        private final String info;

        ErrorInfo(String url, String info) {
            this.url = url;
            this.info = info;
        }
    }
}
      
      



5.7 Spring Security.

. com.springbootsecurityrest SpringSecurityConfig, WebSecurityConfigurerAdapter org.springframework.security.config.annotation.web.configuration. : Configuration EnableWebSecurity.





configure(AuthenticationManagerBuilder auth), AuthenticationManagerBuilder UserService, Spring Security .





@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserService service;

    @Autowired
    private JwtTokenRepository jwtTokenRepository;

    @Autowired
    @Qualifier("handlerExceptionResolver")
    private HandlerExceptionResolver resolver;

    @Bean
    public PasswordEncoder devPasswordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
      @Override
    protected void configure(HttpSecurity http) throws Exception { 
    
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(this.service);
    }

}
      
      



configure(HttpSecurity http):





    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.NEVER)
                .and()
                    .addFilterAt(new JwtCsrfFilter(jwtTokenRepository, resolver), CsrfFilter.class)
                    .csrf().ignoringAntMatchers("/**")
                .and()
                    .authorizeRequests()
                    .antMatchers("/auth/login")
                    .authenticated()
                .and()
                    .httpBasic()
                    .authenticationEntryPoint(((request, response, e) -> resolver.resolveException(request, response, null, e)));
    }
      
      



:





  1. sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER) - ;





  2. addFilterAt(new JwtCsrfFilter(jwtTokenRepository, resolver), CsrfFilter.class).csrf().ignoringAntMatchers("/**") - JwtCsrfFilter , ;





  3. .authorizeRequests().antMatchers("/auth/login").authenticated() /auth/login security. ( ), JwtCsrfFilter;





  4. .httpBasic().authenticationEntryPoint(((request, response, e) -> resolver.resolveException(request, response, null, e))) - GlobalExceptionHandler





6.

Postman. http://localhost:8080/users GET.





Sin token, la validación falló, recibimos un mensaje con el estado 401.





Intentamos iniciar sesión con datos incorrectos, ejecutamos la solicitud http: // localhost: 8080 / auth / login con el tipo POST, la validación falló, no se recibió ningún token, se devolvió un error con estado 401.





Iniciamos sesión con los datos correctos, la autorización está completa, se recibe un usuario autorizado y un token.





Repetimos la solicitud http: // localhost: 8080 / users con el tipo GET, pero con el token recibido en el paso anterior. Obtenemos una lista de usuarios y un token actualizado.





Conclusión

Este artículo analizó uno de los ejemplos de implementación de una aplicación REST con Spring Security y JWT. Espero que esta opción de implementación sea útil para alguien.





El código completo del proyecto está disponible en github.








All Articles