Sooner or later, every Java developer will be faced with the need to implement a secure REST API application. In this article I want to share my implementation of this task.
1. What is REST?
REST (from the English. Representational State Transfer) is the general principles of organizing the interaction of an application / site with a server using the HTTP protocol.
The diagram below shows the general model.
All interaction with the server is reduced to 4 operations (4 is a necessary and sufficient minimum, in a specific implementation there may be more types of operations):
( JSON, XML);
;
;
, REST .
2.
REST , . , . .
:
3.
Spring Boot Spring Web, :
Java 8+;
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- :
parent- spring-boot-starter-parent;
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 :
getByLogin – ;
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).
:
generateToken;
– saveToken;
– 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)));
}
:
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER) - ;
addFilterAt(new JwtCsrfFilter(jwtTokenRepository, resolver), CsrfFilter.class).csrf().ignoringAntMatchers("/**") - JwtCsrfFilter , ;
.authorizeRequests().antMatchers("/auth/login").authenticated() /auth/login security. ( ), JwtCsrfFilter;
.httpBasic().authenticationEntryPoint(((request, response, e) -> resolver.resolveException(request, response, null, e))) - GlobalExceptionHandler
6.
Postman. http://localhost:8080/users GET.
No token, validation failed, we get a message with 401 status.
We try to log in with incorrect data, execute the request http: // localhost: 8080 / auth / login with the POST type, validation failed, no token received, an error with 401 status was returned.
We log in with correct data, authorization is complete, an authorized user and a token are received.
We repeat the request http: // localhost: 8080 / users with the GET type, but with the received token in the previous step. We get a list of users and an updated token.
Conclusion
This article looked at one of the examples of implementing a REST application with Spring Security and JWT. I hope this implementation option will be useful to someone.
The complete project code is available on github