Merge pull request 'fix: implement refresh token functionality and update JWT configuration' (#8) from feature/security into main
Some checks failed
ci/woodpecker/push/pipeline Pipeline failed

Reviewed-on: #8
This commit is contained in:
rov 2025-10-31 13:44:10 -03:00
commit ca2aa5663c
8 changed files with 156 additions and 78 deletions

View File

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

View File

@ -1,9 +1,6 @@
package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.AuthenticationRequestDTO;
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.model.dto.*;
import com.magamochi.mangamochi.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
@ -27,6 +24,17 @@ public class AuthenticationController {
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(
summary = "Register user",
description = "Register a new user.",

View File

@ -5,7 +5,8 @@ import jakarta.validation.constraints.NotNull;
public record AuthenticationResponseDTO(
@NotNull Long id,
@NotNull String token,
@NotNull String accessToken,
@NotNull String refreshToken,
@NotNull String email,
@NotNull String name,
@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;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import com.magamochi.mangamochi.service.CustomUserDetailsService;
import com.magamochi.mangamochi.util.JwtUtil;
import jakarta.servlet.FilterChain;
@ -8,9 +11,9 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@ -18,40 +21,46 @@ import org.springframework.web.filter.OncePerRequestFilter;
@Component
@RequiredArgsConstructor
public class JwtRequestFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain)
throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
if (nonNull(authorizationHeader) && authorizationHeader.startsWith("Bearer ")) {
var jwt = authorizationHeader.substring(7);
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) {
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);
}
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.model.dto.AuthenticationRequestDTO;
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.entity.User;
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.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
@ -33,25 +33,47 @@ public class UserService {
private final PasswordEncoder passwordEncoder;
public AuthenticationResponseDTO authenticate(AuthenticationRequestDTO request) {
var token = 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;
}
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password()));
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());
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) {

View File

@ -2,13 +2,13 @@ package com.magamochi.mangamochi.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
@ -18,51 +18,84 @@ public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.refresh-secret}")
private String refreshSecret;
@Value("${jwt.expiration}")
private Long expiration;
private Key getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes());
}
@Value("${jwt.refresh-expiration}")
private Long refreshExpiration;
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
return extractClaim(token, Claims::getSubject, getAccessSigningKey());
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public String extractUsernameFromRefreshToken(String token) {
return extractClaim(token, Claims::getSubject, getRefreshSigningKey());
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
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) {
public String generateAccessToken(UserDetails userDetails) {
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()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.claims(claims)
.subject(subject)
.issuedAt(now)
.expiration(expiry)
.signWith(key)
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
public Boolean validateAccessToken(String token, UserDetails userDetails) {
var username = extractUsername(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}
bucket: mangamochi
# JWT Configuration
jwt:
secret: mySecretKeymySecretKeymySecretKeymySecretKeymySecretKeymySecretKey
expiration: 86400000 # 24 hours in milliseconds
secret: /JcSdxjeyeuMGoK5GD9w7OfqK/j+nvHR7uVUY12pNis=
expiration: 3600000
refresh-secret: MIV9ctIwrImmrZBjh9QueNEcDOLLVv9Rephii+0DKbk=
refresh-expiration: 2629746000
manga-matcher:
endpoint: ${MANGAMATCHER_ENDPOINT}