fix: implement refresh token functionality and update JWT configuration
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
ci/woodpecker/pr/pipeline Pipeline was successful

This commit is contained in:
Rodrigo Verdiani 2025-10-31 13:31:24 -03:00
parent 55f1710eac
commit 0ec616a45b
8 changed files with 156 additions and 78 deletions

View File

@ -38,8 +38,7 @@ public class SecurityConfig {
@Bean @Bean
public DaoAuthenticationProvider authenticationProvider() { public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); var authProvider = new DaoAuthenticationProvider(userDetailsService);
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder()); authProvider.setPasswordEncoder(passwordEncoder());
return authProvider; return authProvider;
} }

View File

@ -1,9 +1,6 @@
package com.magamochi.mangamochi.controller; package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.AuthenticationRequestDTO; import com.magamochi.mangamochi.model.dto.*;
import com.magamochi.mangamochi.model.dto.AuthenticationResponseDTO;
import com.magamochi.mangamochi.model.dto.DefaultResponseDTO;
import com.magamochi.mangamochi.model.dto.RegistrationRequestDTO;
import com.magamochi.mangamochi.service.UserService; import com.magamochi.mangamochi.service.UserService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -27,6 +24,17 @@ public class AuthenticationController {
return DefaultResponseDTO.ok(userService.authenticate(authenticationRequestDTO)); return DefaultResponseDTO.ok(userService.authenticate(authenticationRequestDTO));
} }
@Operation(
summary = "Refresh authentication token",
description = "Refresh the authentication token",
tags = {"Auth"},
operationId = "refreshAuthToken")
@PostMapping("/refresh")
public DefaultResponseDTO<AuthenticationResponseDTO> refreshAuthToken(
@RequestBody RefreshTokenRequestDTO authenticationRequestDTO) {
return DefaultResponseDTO.ok(userService.refreshAuthToken(authenticationRequestDTO));
}
@Operation( @Operation(
summary = "Register user", summary = "Register user",
description = "Register a new user.", description = "Register a new user.",

View File

@ -5,7 +5,8 @@ import jakarta.validation.constraints.NotNull;
public record AuthenticationResponseDTO( public record AuthenticationResponseDTO(
@NotNull Long id, @NotNull Long id,
@NotNull String token, @NotNull String accessToken,
@NotNull String refreshToken,
@NotNull String email, @NotNull String email,
@NotNull String name, @NotNull String name,
@NotNull UserRole role) {} @NotNull UserRole role) {}

View File

@ -0,0 +1,5 @@
package com.magamochi.mangamochi.model.dto;
import jakarta.validation.constraints.NotNull;
public record RefreshTokenRequestDTO(@NotNull String refreshToken) {}

View File

@ -1,5 +1,8 @@
package com.magamochi.mangamochi.security; package com.magamochi.mangamochi.security;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import com.magamochi.mangamochi.service.CustomUserDetailsService; import com.magamochi.mangamochi.service.CustomUserDetailsService;
import com.magamochi.mangamochi.util.JwtUtil; import com.magamochi.mangamochi.util.JwtUtil;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
@ -8,9 +11,9 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
@ -18,40 +21,46 @@ import org.springframework.web.filter.OncePerRequestFilter;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class JwtRequestFilter extends OncePerRequestFilter { public class JwtRequestFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final CustomUserDetailsService userDetailsService; private final CustomUserDetailsService userDetailsService;
@Override @Override
protected void doFilterInternal( protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain) HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain)
throws ServletException, IOException { throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization"); final String authorizationHeader = request.getHeader("Authorization");
String username = null; if (nonNull(authorizationHeader) && authorizationHeader.startsWith("Bearer ")) {
String jwt = null; var jwt = authorizationHeader.substring(7);
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
try { try {
username = jwtUtil.extractUsername(jwt); var username = jwtUtil.extractUsername(jwt);
if (nonNull(username) && isNull(SecurityContextHolder.getContext().getAuthentication())) {
authenticateUser(username, jwt, request, response);
}
} catch (Exception e) { } catch (Exception e) {
logger.warn("JWT token validation failed", e); response.sendError(
HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired authorization token.");
} }
} }
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(request, response); chain.doFilter(request, response);
} }
private void authenticateUser(
String username, String jwt, HttpServletRequest request, HttpServletResponse response)
throws IOException {
var userDetails = this.userDetailsService.loadUserByUsername(username);
if (!jwtUtil.validateAccessToken(jwt, userDetails)) {
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired authorization token.");
return;
}
var authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
} }

View File

@ -6,6 +6,7 @@ import com.magamochi.mangamochi.exception.ConflictException;
import com.magamochi.mangamochi.exception.NotFoundException; import com.magamochi.mangamochi.exception.NotFoundException;
import com.magamochi.mangamochi.model.dto.AuthenticationRequestDTO; import com.magamochi.mangamochi.model.dto.AuthenticationRequestDTO;
import com.magamochi.mangamochi.model.dto.AuthenticationResponseDTO; import com.magamochi.mangamochi.model.dto.AuthenticationResponseDTO;
import com.magamochi.mangamochi.model.dto.RefreshTokenRequestDTO;
import com.magamochi.mangamochi.model.dto.RegistrationRequestDTO; import com.magamochi.mangamochi.model.dto.RegistrationRequestDTO;
import com.magamochi.mangamochi.model.entity.User; import com.magamochi.mangamochi.model.entity.User;
import com.magamochi.mangamochi.model.enumeration.UserRole; import com.magamochi.mangamochi.model.enumeration.UserRole;
@ -16,7 +17,6 @@ import lombok.extern.log4j.Log4j2;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
@ -33,25 +33,47 @@ public class UserService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
public AuthenticationResponseDTO authenticate(AuthenticationRequestDTO request) { public AuthenticationResponseDTO authenticate(AuthenticationRequestDTO request) {
var token = new UsernamePasswordAuthenticationToken(request.email(), request.password()); authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password()));
try {
authenticationManager.authenticate(token);
} catch (AuthenticationException e) {
if (e.getMessage().equals("Bad credentials")) {
throw new BadCredentialsException("Wrong email or password.");
}
throw e;
}
var userDetails = userDetailsService.loadUserByUsername(request.email()); var userDetails = userDetailsService.loadUserByUsername(request.email());
var jwt = jwtUtil.generateToken(userDetails);
var accessToken = jwtUtil.generateAccessToken(userDetails);
var refreshToken = jwtUtil.generateRefreshToken(userDetails);
var user = findUserByEmailThrowIfNotFound(userDetails.getUsername()); var user = findUserByEmailThrowIfNotFound(userDetails.getUsername());
return new AuthenticationResponseDTO( return new AuthenticationResponseDTO(
user.getId(), jwt, userDetails.getUsername(), user.getName(), user.getRole()); user.getId(),
accessToken,
refreshToken,
userDetails.getUsername(),
user.getName(),
user.getRole());
}
public AuthenticationResponseDTO refreshAuthToken(
RefreshTokenRequestDTO authenticationRequestDTO) {
var username = jwtUtil.extractUsernameFromRefreshToken(authenticationRequestDTO.refreshToken());
var userDetails = userDetailsService.loadUserByUsername(username);
if (!jwtUtil.validateRefreshToken(authenticationRequestDTO.refreshToken(), userDetails)) {
throw new BadCredentialsException("Invalid refresh token");
}
var newAccessToken = jwtUtil.generateAccessToken(userDetails);
var newRefreshToken = jwtUtil.generateRefreshToken(userDetails);
var user = findUserByEmailThrowIfNotFound(userDetails.getUsername());
return new AuthenticationResponseDTO(
user.getId(),
newAccessToken,
newRefreshToken,
userDetails.getUsername(),
user.getName(),
user.getRole());
} }
public void register(RegistrationRequestDTO request) { public void register(RegistrationRequestDTO request) {

View File

@ -2,13 +2,13 @@ package com.magamochi.mangamochi.util;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.function.Function; import java.util.function.Function;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -18,51 +18,84 @@ public class JwtUtil {
@Value("${jwt.secret}") @Value("${jwt.secret}")
private String secret; private String secret;
@Value("${jwt.refresh-secret}")
private String refreshSecret;
@Value("${jwt.expiration}") @Value("${jwt.expiration}")
private Long expiration; private Long expiration;
private Key getSigningKey() { @Value("${jwt.refresh-expiration}")
return Keys.hmacShaKeyFor(secret.getBytes()); private Long refreshExpiration;
}
public String extractUsername(String token) { public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject); return extractClaim(token, Claims::getSubject, getAccessSigningKey());
} }
public Date extractExpiration(String token) { public String extractUsernameFromRefreshToken(String token) {
return extractClaim(token, Claims::getExpiration); return extractClaim(token, Claims::getSubject, getRefreshSigningKey());
} }
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) { public String generateAccessToken(UserDetails userDetails) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser().setSigningKey(getSigningKey()).build().parseClaimsJws(token).getBody();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>(); Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername()); return createToken(claims, userDetails.getUsername(), expiration, getAccessSigningKey());
} }
private String createToken(Map<String, Object> claims, String subject) { public String generateRefreshToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(
claims, userDetails.getUsername(), refreshExpiration, getRefreshSigningKey());
}
private String createToken(
Map<String, Object> claims, String subject, Long duration, SecretKey key) {
var now = new Date();
var expiry = new Date(now.getTime() + duration);
return Jwts.builder() return Jwts.builder()
.setClaims(claims) .claims(claims)
.setSubject(subject) .subject(subject)
.setIssuedAt(new Date(System.currentTimeMillis())) .issuedAt(now)
.setExpiration(new Date(System.currentTimeMillis() + expiration)) .expiration(expiry)
.signWith(getSigningKey(), SignatureAlgorithm.HS256) .signWith(key)
.compact(); .compact();
} }
public Boolean validateToken(String token, UserDetails userDetails) { public Boolean validateAccessToken(String token, UserDetails userDetails) {
final String username = extractUsername(token); var username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
} }
public boolean validateRefreshToken(String token, UserDetails userDetails) {
var username = extractUsernameFromRefreshToken(token);
return (username.equals(userDetails.getUsername()) && !isRefreshTokenExpired(token));
}
private SecretKey getAccessSigningKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
private SecretKey getRefreshSigningKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(refreshSecret));
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver, SecretKey key) {
final Claims claims = parseClaims(token, key);
return claimsResolver.apply(claims);
}
private Claims parseClaims(String token, SecretKey key) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token, getAccessSigningKey()).before(new Date());
}
private Boolean isRefreshTokenExpired(String token) {
return extractExpiration(token, getRefreshSigningKey()).before(new Date());
}
public Date extractExpiration(String token, SecretKey key) {
return extractClaim(token, Claims::getExpiration, key);
}
} }

View File

@ -44,10 +44,11 @@ minio:
secretKey: ${MINIO_PASS} secretKey: ${MINIO_PASS}
bucket: mangamochi bucket: mangamochi
# JWT Configuration
jwt: jwt:
secret: mySecretKeymySecretKeymySecretKeymySecretKeymySecretKeymySecretKey secret: /JcSdxjeyeuMGoK5GD9w7OfqK/j+nvHR7uVUY12pNis=
expiration: 86400000 # 24 hours in milliseconds expiration: 3600000
refresh-secret: MIV9ctIwrImmrZBjh9QueNEcDOLLVv9Rephii+0DKbk=
refresh-expiration: 2629746000
manga-matcher: manga-matcher:
endpoint: ${MANGAMATCHER_ENDPOINT} endpoint: ${MANGAMATCHER_ENDPOINT}