Stworzymy proste REST API umożliwiające zalogowanie się użytkownika do serwisu oraz udostępniające kilka endpointów, do których dostęp będzie wymagał autoryzacji.
Po udanej autoryzacji zostanie zwrócony JSON Web Token, którego przesłanie w nagłówku żądania HTTP będzie wymagane podczas dostępu do chronionych endpointów.
Kod źródłowy projektu znajdziesz na GitHubie: https://github.com/javascratches/jwt-rest-api
Zdefniujmy kontroler udostępniający endpoint logowania
@RestController
@RequestMapping("/login")
@RequiredArgsConstructor
public class LoginController {
private final AuthenticationManager authenticationManager;
private final TokenService tokenService;
@PostMapping
public TokenResponse login(@RequestBody LoginRequest loginRequest) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword());
authenticationManager.authenticate(authentication);
return new TokenResponse(tokenService.generateToken(loginRequest.getUsername()));
}
}
Użytkownik przekazany w parametrach żądania jest autoryzowany za pomocą authenticationManager
. W przypadku udanej autoryzacji generowany jest token za pomocą metody tokenService.generateToken
. Token zawiera nazwę użytkownika, datę utworzenia i ważności. Podpisywany jest kluczem, którego wartość ustawiono w konfiguracji.
public String generateToken(String username) {
Map<String, Object> claims = new HashMap<>();
return doGenerateToken(claims, username);
}
private String doGenerateToken(Map<String, Object> claims, String subject) {
long currentTime = System.currentTimeMillis();
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(currentTime))
.setExpiration(new Date(currentTime + validity * 1000))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
Konfiguracja Spring Security oparta jest o klasy konfiguracyjne (począwszy od wersji Spring Boot 2.7, konfiguracja z wykorzystaniem WebSecurityConfigurerAdapter
jest deprecated)
.
Bean AuthenticationManager
, wstrzykiwany w kontrolerze dostarczany jest przez klasę konfiguracyjną:
@Configuration
public class AuthenticationManagerConfig {
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
Manager użytkowników oparty na InMemoryUserDetailsManager
:
@Configuration
@RequiredArgsConstructor
public class UserDetailsManagerConfig {
private final PasswordEncoder passwordEncoder;
@Bean
public InMemoryUserDetailsManager userDetailsManager() {
UserDetails user = User.withUsername("user")
.password(passwordEncoder.encode("sekret1"))
.roles("USER")
.build();
UserDetails admin = User.withUsername("admin")
.password(passwordEncoder.encode("sekret2"))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
}
Konfiguracja dostępu do endpointów i filtrowania:
@Configuration
@RequiredArgsConstructor
public class SpringSecurityConfig {
private final JwtRequestFilter jwtRequestFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authz -> authz
.antMatchers("/login").permitAll()
.anyRequest().authenticated())
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Jak widzisz, wszystkie endpointy oprócz /login
są chronione autoryzacją. Dodany jest również filtr zdefiniowany w jwtRequestFilter
. Filtr ten odpowiedzialny jest za sprawdzenie poprawności przesyłanego tokenu.
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtRequestFilter extends OncePerRequestFilter {
private final TokenService tokenService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String requestTokenHeader = request.getHeader("Authorization");
String username = null;
String jwtToken = null;
// Pobieramy token z nagłówka
if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
jwtToken = requestTokenHeader.substring(7);
try {
username = tokenService.getUsernameFromToken(jwtToken);
} catch (IllegalArgumentException e) {
log.error("Unable to get JWT Token");
} catch (ExpiredJwtException e) {
log.error("JWT Token has expired");
}
}
// Walidacja poprawności tokena
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (tokenService.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
userDetails, userDetails.getPassword(), userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
chain.doFilter(request, response);
}
}
Walidacja polega na pobraniu i sprawdzeniu poprawności nazwy użytkownika przesłanej w tokenie. Metoda tokenService.validateToken
sprawdza również, czy token jest nadal ważny.
Pozostał nam jeszcze kontroler udostępniający prosty endpoint, którego zadaniem będzie zwrócenie komunikatu dla zalogowanego użytkownika:
@RestController
@RequestMapping("/hello")
public class HelloController {
@GetMapping
public HelloResponse hello(@AuthenticationPrincipal User user) {
return new HelloResponse("Hello " + user.getUsername());
}
}
Przetestujmy endpoint /hello
bez zalogowania się.
curl -i -X GET http://localhost:8080/hello
HTTP/1.1 403
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Sun, 29 May 2022 09:11:10 GMT
Dostaliśmy błąd 403 Forbidden.
Zalogujmy się jako użytkownik admin:
curl -i -X POST http://localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"sekret2"}'
Odpowiedź zawiera token:
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 29 May 2022 09:13:58 GMT
{"token":"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTY1MzgxNTY5OCwiaWF0IjoxNjUzODE1NjM4fQ.rK4imjlT4WSzClMTNtBQkl29CQvK-NFXG3CZ3ltaQeMgLMYMEaFzlNRxMBnoYH28R3f1_g4SX4J-UwNRgOGDQg"}
Otrzymany token dołączamy do nagłówka żądania:
curl -i -X GET http://localhost:8080/hello \
-H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTY1MzgxNTgzOCwiaWF0IjoxNjUzODE1Nzc4fQ.1YfHz6jmjl1UsX3AGznMMJGxp4wtn7Y11ltSVquWBV0gGHRn7U1L686gWGJWSdmRiIisgizMmJXfBftFVPt_Lg'
Tym razem otrzymujemy status 200:
HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 29 May 2022 09:16:36 GMT{"message":"Hello admin"}
Znając klucz, możesz wygenerować token na stronie jwt.io
Generując token pamiętaj aby ustawić właściwy czas wygenerowania iat
i ważności tokena exp
. Pole sub
zawiera nazwę użytkownika. Czas podajemy w formacie epoch – możesz skorzystać ze strony https://www.epochconverter.com/