diff --git a/src/main/java/org/decepticons/linkshortener/LinkShortenerApplication.java b/src/main/java/org/decepticons/linkshortener/LinkShortenerApplication.java
index 0c58d29..a0f235b 100644
--- a/src/main/java/org/decepticons/linkshortener/LinkShortenerApplication.java
+++ b/src/main/java/org/decepticons/linkshortener/LinkShortenerApplication.java
@@ -3,6 +3,7 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Entry point for the Link Shortener application.
@@ -11,6 +12,7 @@
*
*/
@EnableCaching
+@EnableScheduling
@SpringBootApplication
public class LinkShortenerApplication {
diff --git a/src/main/java/org/decepticons/linkshortener/api/exception/ExpiredTokenException.java b/src/main/java/org/decepticons/linkshortener/api/exception/ExpiredTokenException.java
index 2b92426..2753459 100644
--- a/src/main/java/org/decepticons/linkshortener/api/exception/ExpiredTokenException.java
+++ b/src/main/java/org/decepticons/linkshortener/api/exception/ExpiredTokenException.java
@@ -1,6 +1,5 @@
package org.decepticons.linkshortener.api.exception;
-import org.decepticons.linkshortener.api.exception.BaseException;
/**
* Thrown when a JWT token is expired.
diff --git a/src/main/java/org/decepticons/linkshortener/api/model/RevokedToken.java b/src/main/java/org/decepticons/linkshortener/api/model/RevokedToken.java
new file mode 100644
index 0000000..98d459c
--- /dev/null
+++ b/src/main/java/org/decepticons/linkshortener/api/model/RevokedToken.java
@@ -0,0 +1,59 @@
+package org.decepticons.linkshortener.api.model;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.Instant;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * Entity representing a revoked JWT token in the database.
+ * This is used for stateless token blacklisting.
+ */
+@Entity
+@Table(name = "revoked_tokens")
+@Getter
+@Setter
+@NoArgsConstructor
+public class RevokedToken {
+
+ /**
+ * The maximum length of a token string.
+ */
+ private static final int MAX_TOKEN_LENGTH = 500;
+
+ /**
+ * The unique identifier for the revoked token entry.
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /**
+ * The revoked JWT token string.
+ */
+ @Column(nullable = false, unique = true, length = MAX_TOKEN_LENGTH)
+ private String token;
+
+ /**
+ * The date and time at which the token expires.
+ */
+ @Column(name = "expires_at", nullable = false)
+ private Instant expiresAt;
+
+ /**
+ * Constructs a new RevokedToken with the given token and expiration date.
+ *
+ * @param tokenParam the JWT token string to be revoked
+ * @param expiresAtParam the expiration date of the token
+ */
+ public RevokedToken(final String tokenParam, final Instant expiresAtParam) {
+ this.token = tokenParam;
+ this.expiresAt = expiresAtParam;
+ }
+}
diff --git a/src/main/java/org/decepticons/linkshortener/api/repository/RevokedTokenRepository.java b/src/main/java/org/decepticons/linkshortener/api/repository/RevokedTokenRepository.java
new file mode 100644
index 0000000..aaeafe6
--- /dev/null
+++ b/src/main/java/org/decepticons/linkshortener/api/repository/RevokedTokenRepository.java
@@ -0,0 +1,39 @@
+package org.decepticons.linkshortener.api.repository;
+
+import java.time.Instant;
+import java.util.Optional;
+import org.decepticons.linkshortener.api.model.RevokedToken;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+/**
+ * Repository for managing revoked JWT tokens.
+ */
+@Repository
+public interface RevokedTokenRepository extends
+ JpaRepository {
+
+ /**
+ * Finds a revoked token by its unique token string.
+ *
+ * @param token The token string to search for.
+ * @return An Optional containing the found RevokedToken,
+ * or empty if not found.
+ */
+ Optional findByToken(String token);
+
+ /**
+ * Checks if a token exists in the repository.
+ *
+ * @param token The token string to check.
+ * @return true if the token exists, false otherwise.
+ */
+ boolean existsByToken(String token);
+
+ /**
+ * Deletes all revoked tokens that have expired before the given timestamp.
+ *
+ * @param now The timestamp used to determine which tokens to delete.
+ */
+ void deleteAllByExpiresAtBefore(Instant now);
+}
diff --git a/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java b/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java
index 34bb375..9864037 100644
--- a/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java
+++ b/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java
@@ -8,6 +8,7 @@
import org.decepticons.linkshortener.api.model.User;
import org.decepticons.linkshortener.api.repository.UserRepository;
import org.decepticons.linkshortener.api.security.jwt.JwtAuthenticationFilter;
+import org.decepticons.linkshortener.api.security.model.CustomUserDetails;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
@@ -117,10 +118,7 @@ public UserDetailsService userDetailsService(
"User was not found"
));
- return org.springframework.security.core.userdetails.User
- .withUsername(user.getUsername())
- .password(user.getPasswordHash())
- .build();
+ return new CustomUserDetails(user);
};
}
diff --git a/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java
index 5dddb72..6eddd80 100644
--- a/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java
+++ b/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java
@@ -5,7 +5,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
-import org.decepticons.linkshortener.api.exception.InvalidTokenException;
+import org.decepticons.linkshortener.api.repository.RevokedTokenRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -21,42 +21,64 @@
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
- /** Logger for JWT authentication filter. */
- private static final Logger LOG =
- LoggerFactory.getLogger(JwtAuthenticationFilter.class);
- /** HTTP header containing the JWT token. */
+ /**
+ * Logger for the JwtAuthenticationFilter class.
+ */
+ private static final Logger LOG = LoggerFactory.getLogger(
+ JwtAuthenticationFilter.class
+ );
+
+ /**
+ * The name of the authorization header.
+ */
private static final String AUTHORIZATION_HEADER = "Authorization";
- /** Prefix used in the Authorization header. */
+ /**
+ * The prefix for a Bearer token.
+ */
private static final String BEARER_PREFIX = "Bearer ";
- /** Utility class for JWT operations. */
+ /**
+ * Utility for handling JWT tokens.
+ */
private final JwtTokenUtil jwtTokenUtil;
- /** Service to load user details from the database. */
+ /**
+ * Service for loading user details.
+ */
private final UserDetailsService userDetailsService;
/**
- * Constructs a JwtAuthenticationFilter with required dependencies.
+ * Repository to check for revoked tokens.
+ */
+ private final RevokedTokenRepository revokedTokenRepository;
+
+ /**
+ * Constructs a new JwtAuthenticationFilter.
*
- * @param inJwtTokenUtil utility to parse and validate JWT tokens
- * @param inUserDetailsService service to load user details
+ * @param jwtTokenUtilParam The JWT utility.
+ * @param userDetailsServiceParam The user details service.
+ * @param revokedTokenRepositoryParam The revoked token repository.
*/
- public JwtAuthenticationFilter(final JwtTokenUtil inJwtTokenUtil,
- final UserDetailsService inUserDetailsService) {
- this.jwtTokenUtil = inJwtTokenUtil;
- this.userDetailsService = inUserDetailsService;
+ public JwtAuthenticationFilter(
+ final JwtTokenUtil jwtTokenUtilParam,
+ final UserDetailsService userDetailsServiceParam,
+ final RevokedTokenRepository revokedTokenRepositoryParam
+ ) {
+ this.jwtTokenUtil = jwtTokenUtilParam;
+ this.userDetailsService = userDetailsServiceParam;
+ this.revokedTokenRepository = revokedTokenRepositoryParam;
}
/**
- * Filters each request to validate JWT tokens and set authentication.
+ * Filters incoming requests to validate JWT tokens and authenticate users.
*
- * @param request the HTTP request
- * @param response the HTTP response
- * @param filterChain the filter chain
- * @throws ServletException if a servlet error occurs
- * @throws IOException if an I/O error occurs
+ * @param request The servlet request.
+ * @param response The servlet response.
+ * @param filterChain The filter chain.
+ * @throws ServletException if a servlet-specific error occurs.
+ * @throws IOException if an I/O error occurs.
*/
@Override
protected void doFilterInternal(final HttpServletRequest request,
@@ -65,20 +87,24 @@ protected void doFilterInternal(final HttpServletRequest request,
throws ServletException, IOException {
final String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);
- String jwtToken;
- if (authorizationHeader == null) {
- LOG.debug("No JWT token, skipping authentication");
+ if (authorizationHeader == null
+ || !authorizationHeader.startsWith(BEARER_PREFIX)) {
filterChain.doFilter(request, response);
return;
}
- if (!authorizationHeader.startsWith(BEARER_PREFIX)) {
- LOG.warn("JWT Token does not begin with Bearer String");
- throw new InvalidTokenException("JWT Token must start with 'Bearer '");
- }
+ String jwtToken = authorizationHeader.substring(BEARER_PREFIX.length());
- jwtToken = authorizationHeader.substring(BEARER_PREFIX.length());
+ // Check if the token is revoked
+ if (revokedTokenRepository.existsByToken(jwtToken)) {
+ LOG.warn("Revoked token used: {}", jwtToken);
+ SecurityContextHolder.clearContext();
+ response.sendError(
+ HttpServletResponse.SC_UNAUTHORIZED,
+ "Token has been revoked");
+ return;
+ }
final String username = jwtTokenUtil.extractUsername(jwtToken);
@@ -93,14 +119,21 @@ protected void doFilterInternal(final HttpServletRequest request,
}
if (!jwtTokenUtil.validateToken(jwtToken, userDetails)) {
- throw new InvalidTokenException("Token is expired or invalid");
+ SecurityContextHolder.clearContext();
+ response.sendError(
+ HttpServletResponse.SC_UNAUTHORIZED,
+ "Token is expired or invalid");
+ return;
}
UsernamePasswordAuthenticationToken authToken =
- new UsernamePasswordAuthenticationToken(userDetails, null,
+ new UsernamePasswordAuthenticationToken(
+ userDetails,
+ null,
userDetails.getAuthorities());
authToken.setDetails(
- new WebAuthenticationDetailsSource().buildDetails(request));
+ new WebAuthenticationDetailsSource().buildDetails(request)
+ );
SecurityContextHolder.getContext().setAuthentication(authToken);
}
@@ -108,10 +141,10 @@ protected void doFilterInternal(final HttpServletRequest request,
}
/**
- * Determines if this filter should not apply to a given request.
+ * Determines whether this filter should be applied to the current request.
*
- * @param request the HTTP request
- * @return true if filter should be skipped, false otherwise
+ * @param request The servlet request.
+ * @return true if the filter should not be applied, false otherwise.
*/
@Override
protected boolean shouldNotFilter(final HttpServletRequest request) {
@@ -125,5 +158,4 @@ protected boolean shouldNotFilter(final HttpServletRequest request) {
|| path.startsWith("/swagger-ui")
|| path.startsWith("/v3/api-docs");
}
-
}
diff --git a/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtil.java b/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtil.java
index 7220ad5..d018087 100644
--- a/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtil.java
+++ b/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtil.java
@@ -18,31 +18,39 @@
/**
* Utility class for generating and validating JWT tokens.
- * Provides methods to create JWT tokens for authenticated users,
- * refresh tokens, extract claims, and validate expiration.
+ * Provides methods to create JWT tokens for
+ * authenticated users, refresh tokens, extract claims,
+ * and validate expiration.
*/
@Component
public class JwtTokenUtil {
- /** The number of milliseconds in a second. */
+ /**
+ * The number of milliseconds in a second.
+ */
private static final long MILLISECONDS_IN_A_SECOND = 1000L;
- /** The signing key. */
+ /**
+ * The signing key.
+ */
private final Key signingKey;
- /** The token validity duration for access tokens in seconds. */
+ /**
+ * The token validity duration for access tokens in seconds.
+ */
private final long expirationSeconds;
- /** The token validity duration for refresh tokens in seconds. */
+ /**
+ * The token validity duration for refresh tokens in seconds.
+ */
private final long refreshExpirationSeconds;
/**
* Constructs a JwtTokenUtil with the given secrets and expiration times.
*
* @param secretValue the secret key for signing JWT tokens
- * @param expirationSecondsValue the access token validity duration in seconds
- * @param refreshExpirationValue the refresh token validity
- * duration in seconds
+ * @param expirationSecondsValue the access token validity duration (seconds)
+ * @param refreshExpirationValue the refresh token validity duration (seconds)
*/
public JwtTokenUtil(
@Value("${JWT_SECRET}") final String secretValue,
@@ -81,7 +89,7 @@ public String generateRefreshToken(final UserDetails userDetails) {
/**
* Validates a JWT token against user details and expiration.
*
- * @param token the JWT token to validate.
+ * @param token the JWT token to validate.
* @param userDetails the user details to validate against.
* @return true if the token is valid, false otherwise.
*/
@@ -97,6 +105,20 @@ public boolean validateToken(
}
}
+ /**
+ * Validates a JWT token by checking if it is expired.
+ *
+ * @param token the JWT token to validate.
+ * @return true if the token is valid and not expired, false otherwise.
+ */
+ public boolean validateToken(final String token) {
+ try {
+ return !isTokenExpired(token);
+ } catch (InvalidTokenException e) {
+ return false;
+ }
+ }
+
/**
* Extracts the username (subject) from a JWT token.
*
@@ -110,10 +132,10 @@ public String extractUsername(final String token) {
/**
* Extracts a specific claim from a JWT token.
*
- * @param the type of the claim to extract
- * @param token the JWT token from which to extract the claim
+ * @param the type of the claim to extract
+ * @param token the JWT token from which to extract the claim
* @param claimsResolver a function to resolve a specific claim
- * from the token's claims
+ * from the token's claims
* @return the extracted claim
*/
public T extractClaim(
@@ -136,8 +158,8 @@ public Date extractExpiration(final String token) {
/**
* Builds a JWT token with claims and subject.
*
- * @param claims the claims to be included in the token.
- * @param subject the subject of the token (usually the username).
+ * @param claims the claims to be included in the token.
+ * @param subject the subject of the token (username).
* @param expirationSecondsParam the token validity in seconds.
* @return the built JWT token string.
*/
@@ -165,13 +187,13 @@ private String createToken(
private Claims extractAllClaims(final String token) {
try {
return Jwts.parserBuilder()
- .setSigningKey(signingKey)
- .build()
- .parseClaimsJws(token)
- .getBody();
+ .setSigningKey(signingKey)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
} catch (JwtException | IllegalArgumentException ex) {
throw new InvalidTokenException(
- "Invalid token provided during claims extraction.", ex);
+ "Invalid token provided during claims extraction.", ex);
}
}
diff --git a/src/main/java/org/decepticons/linkshortener/api/security/model/CustomUserDetails.java b/src/main/java/org/decepticons/linkshortener/api/security/model/CustomUserDetails.java
index 5321016..dc1f1a4 100644
--- a/src/main/java/org/decepticons/linkshortener/api/security/model/CustomUserDetails.java
+++ b/src/main/java/org/decepticons/linkshortener/api/security/model/CustomUserDetails.java
@@ -4,6 +4,7 @@
import java.util.Collection;
import lombok.Getter;
import org.decepticons.linkshortener.api.model.User;
+import org.decepticons.linkshortener.api.model.UserStatus;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
@@ -34,7 +35,7 @@ public CustomUserDetails(final User userParam) {
/**
* Returns the authorities granted to the user.
*
- * @return collection of granted authorities
+ * @return collection of granted authorities (roles)
*/
@Override
public Collection extends GrantedAuthority> getAuthorities() {
@@ -63,7 +64,6 @@ public String getUsername() {
/**
* Indicates whether the user's account has expired.
- * Subclasses may override to change the expiration logic.
*
* @return true if the account is not expired
*/
@@ -74,23 +74,32 @@ public boolean isAccountNonExpired() {
/**
* Indicates whether the user's account is locked.
- * Subclasses may override to change the lock logic.
*
- * @return true if the account is not locked
+ * @return true if the user is not LOCKED
*/
@Override
public boolean isAccountNonLocked() {
- return true;
+ return user.getStatus() != null && user.getStatus() != UserStatus.LOCKED;
}
/**
* Indicates whether the user's credentials are expired.
- * Subclasses may override to change the credential expiration logic.
*
- * @return true if the credentials are valid
+ * @return true if credentials are valid
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
+
+ /**
+ * Indicates whether the user is enabled.
+ * Here we consider ACTIVE as enabled, LOCKED as disabled.
+ *
+ * @return true if user is ACTIVE
+ */
+ @Override
+ public boolean isEnabled() {
+ return user.getStatus() != null && user.getStatus() == UserStatus.ACTIVE;
+ }
}
diff --git a/src/main/java/org/decepticons/linkshortener/api/security/service/AuthService.java b/src/main/java/org/decepticons/linkshortener/api/security/service/AuthService.java
index 9752e0e..8243517 100644
--- a/src/main/java/org/decepticons/linkshortener/api/security/service/AuthService.java
+++ b/src/main/java/org/decepticons/linkshortener/api/security/service/AuthService.java
@@ -34,4 +34,11 @@ public interface AuthService {
* @return the newly registered User domain object
*/
User registerUser(User user);
+
+ /**
+ * Logs out the user by revoking their token.
+ *
+ * @param token the token to be revoked
+ */
+ void logout(String token);
}
diff --git a/src/main/java/org/decepticons/linkshortener/api/security/service/UserAuthService.java b/src/main/java/org/decepticons/linkshortener/api/security/service/UserAuthService.java
deleted file mode 100644
index f6fd81a..0000000
--- a/src/main/java/org/decepticons/linkshortener/api/security/service/UserAuthService.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package org.decepticons.linkshortener.api.security.service;
-
-import org.decepticons.linkshortener.api.model.User;
-
-/**
- * Service interface for user-related authentication and data retrieval.
- * This service works exclusively with the User domain object.
- */
-public interface UserAuthService {
-
- /**
- * Finds a user by their username.
- *
- * @param username the username to search for
- * @return the User object
- */
- User findByUsername(String username);
-
- /**
- * Registers a new user.
- *
- * @param user the User domain object to register
- * @return the registered User object
- */
- User registerUser(User user);
-}
diff --git a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java
index 9a4958f..5d7636d 100644
--- a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java
+++ b/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java
@@ -1,17 +1,24 @@
package org.decepticons.linkshortener.api.security.service.impl;
+import jakarta.transaction.Transactional;
+import java.time.Instant;
import java.util.Collections;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
+import org.decepticons.linkshortener.api.exception.InvalidPasswordException;
import org.decepticons.linkshortener.api.exception.InvalidTokenException;
+import org.decepticons.linkshortener.api.exception.UserAlreadyExistsException;
+import org.decepticons.linkshortener.api.model.RevokedToken;
import org.decepticons.linkshortener.api.model.Role;
import org.decepticons.linkshortener.api.model.User;
import org.decepticons.linkshortener.api.model.UserStatus;
+import org.decepticons.linkshortener.api.repository.RevokedTokenRepository;
import org.decepticons.linkshortener.api.repository.RoleRepository;
import org.decepticons.linkshortener.api.repository.UserRepository;
import org.decepticons.linkshortener.api.security.jwt.JwtTokenUtil;
import org.decepticons.linkshortener.api.security.service.AuthService;
-import org.decepticons.linkshortener.api.security.service.UserAuthService;
+import org.decepticons.linkshortener.api.service.UserService;
+import org.decepticons.linkshortener.api.util.PasswordValidator;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -19,7 +26,8 @@
/**
* Service implementation for user authentication and authorization.
- * Handles user login, token refresh, and registration processes.
+ * Handles user login, token
+ * refresh, and registration processes.
*/
@Service
@RequiredArgsConstructor
@@ -33,7 +41,7 @@ public class AuthServiceImpl implements AuthService {
/**
* Service for handling user-related authentication operations.
*/
- private final UserAuthService userAuthService;
+ private final UserService userService;
/**
* Manages authentication requests and processes them.
@@ -50,6 +58,11 @@ public class AuthServiceImpl implements AuthService {
*/
private final UserRepository userRepository;
+ /**
+ * Repository for managing revoked JWT tokens.
+ */
+ private final RevokedTokenRepository revokedTokenRepository;
+
/**
* Encodes and verifies user passwords.
*/
@@ -72,7 +85,7 @@ public User login(final String username, final String password) {
authManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password)
);
- return userAuthService.findByUsername(username);
+ return userService.findByUsername(username);
}
/**
@@ -84,15 +97,26 @@ public User login(final String username, final String password) {
*/
@Override
public User refreshToken(final String authorizationHeader) {
- if (authorizationHeader == null
- || !authorizationHeader.startsWith(BEARER_PREFIX)) {
+ if (
+ authorizationHeader == null
+ || !authorizationHeader.startsWith(BEARER_PREFIX)
+ ) {
throw new InvalidTokenException(
"Missing or malformed Authorization header"
);
}
String jwtToken = authorizationHeader.substring(BEARER_PREFIX.length());
+
+ if (revokedTokenRepository.existsByToken(jwtToken)) {
+ throw new InvalidTokenException("Token has been revoked");
+ }
+
+ if (!jwtUtil.validateToken(jwtToken)) {
+ throw new InvalidTokenException("Invalid or expired token");
+ }
+
String username = jwtUtil.extractUsername(jwtToken);
- return userAuthService.findByUsername(username);
+ return userService.findByUsername(username);
}
/**
@@ -103,6 +127,7 @@ public User refreshToken(final String authorizationHeader) {
* @throws IllegalStateException if the default 'ROLE_USER' role is not found.
*/
@Override
+ @Transactional
public User registerUser(final User user) {
Optional userRole = roleRepository.findByName("ROLE_USER");
if (userRole.isEmpty()) {
@@ -110,8 +135,41 @@ public User registerUser(final User user) {
"Default role 'ROLE_USER' not found in the database."
);
}
+
+ if (userRepository.existsByUsername(user.getUsername())) {
+ throw new UserAlreadyExistsException(user.getUsername());
+ }
+
+ if (!PasswordValidator.isValid(user.getPasswordHash())) {
+ throw new InvalidPasswordException(
+ "Password does not meet complexity requirements"
+ );
+ }
+
+ user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
user.setStatus(UserStatus.ACTIVE);
user.setRoles(Collections.singleton(userRole.get()));
- return userAuthService.registerUser(user);
+ return userRepository.save(user);
+ }
+
+ /**
+ * Logs out the user by revoking their token.
+ *
+ * @param authHeader the Authorization header containing
+ * the token to be revoked
+ */
+ @Override
+ @Transactional
+ public void logout(final String authHeader) {
+ if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
+ throw new InvalidTokenException(
+ "Missing or malformed Authorization header"
+ );
+ }
+
+ String accessToken = authHeader.substring(BEARER_PREFIX.length());
+ Instant accessExpiresAt = jwtUtil.extractExpiration(accessToken)
+ .toInstant();
+ revokedTokenRepository.save(new RevokedToken(accessToken, accessExpiresAt));
}
}
diff --git a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImpl.java
deleted file mode 100644
index fb448d9..0000000
--- a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImpl.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package org.decepticons.linkshortener.api.security.service.impl;
-
-import jakarta.transaction.Transactional;
-import lombok.RequiredArgsConstructor;
-import org.decepticons.linkshortener.api.exception.InvalidPasswordException;
-import org.decepticons.linkshortener.api.exception.UserAlreadyExistsException;
-import org.decepticons.linkshortener.api.exception.UserNotFoundException;
-import org.decepticons.linkshortener.api.model.User;
-import org.decepticons.linkshortener.api.repository.UserRepository;
-import org.decepticons.linkshortener.api.security.service.UserAuthService;
-import org.decepticons.linkshortener.api.util.PasswordValidator;
-import org.springframework.security.crypto.password.PasswordEncoder;
-import org.springframework.stereotype.Service;
-
-/**
- * Implementation of {@link UserAuthService} that handles user registration
- * and retrieval by username. Uses {@link UserRepository} for data access
- * and {@link PasswordEncoder} for hashing passwords.
- */
-@Service
-@RequiredArgsConstructor
-public class UserAuthServiceImpl implements UserAuthService {
-
- /** Repository for user data access. */
- private final UserRepository userRepository;
-
- /** Encoder for hashing user passwords. */
- private final PasswordEncoder passwordEncoder;
-
- /**
- * Registers a new user.
- * Throws an exception if the username already exists.
- *
- * @param newUser the User domain object to be registered
- * @return the newly registered User object
- */
- @Override
- @Transactional
- public User registerUser(final User newUser) {
- String username = newUser.getUsername();
- String password = newUser.getPasswordHash();
-
- if (userRepository.existsByUsername(username)) {
- throw new UserAlreadyExistsException(username);
- }
-
- if (!PasswordValidator.isValid(password)) {
- throw new InvalidPasswordException(
- "Password does not meet complexity requirements"
- );
- }
-
- newUser.setPasswordHash(passwordEncoder.encode(password));
- userRepository.save(newUser);
-
- return newUser;
- }
-
- /**
- * Finds a user by their username.
- *
- * @param username the username to search for
- * @return the User entity
- * @throws UserNotFoundException if no user is found
- */
- @Override
- public User findByUsername(final String username) {
- return userRepository.findByUsername(username)
- .orElseThrow(() -> new UserNotFoundException(username)
- );
- }
-}
diff --git a/src/main/java/org/decepticons/linkshortener/api/service/RevokedTokenCleanupService.java b/src/main/java/org/decepticons/linkshortener/api/service/RevokedTokenCleanupService.java
new file mode 100644
index 0000000..59a9da8
--- /dev/null
+++ b/src/main/java/org/decepticons/linkshortener/api/service/RevokedTokenCleanupService.java
@@ -0,0 +1,11 @@
+package org.decepticons.linkshortener.api.service;
+
+/**
+ * Service interface for cleaning up revoked tokens.
+ */
+public interface RevokedTokenCleanupService {
+ /**
+ * Service interface for cleaning up revoked tokens.
+ */
+ void cleanupExpiredRevokedTokens();
+}
diff --git a/src/main/java/org/decepticons/linkshortener/api/service/UserService.java b/src/main/java/org/decepticons/linkshortener/api/service/UserService.java
index 3cd29b3..98c6afa 100644
--- a/src/main/java/org/decepticons/linkshortener/api/service/UserService.java
+++ b/src/main/java/org/decepticons/linkshortener/api/service/UserService.java
@@ -4,7 +4,8 @@
import org.decepticons.linkshortener.api.model.User;
/**
- * Service interface for retrieving information about the currently authenticated user.
+ * Service interface for retrieving information
+ * about the currently authenticated user.
*/
public interface UserService {
@@ -21,4 +22,13 @@ public interface UserService {
* @return the UUID of the current user
*/
UUID getCurrentUserId();
+
+ /**
+ * Finds a user by their username.
+ *
+ * @param username the username to search for
+ * @return the User object
+ */
+ User findByUsername(String username);
+
}
diff --git a/src/main/java/org/decepticons/linkshortener/api/service/impl/RevokedTokenCleanupServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/service/impl/RevokedTokenCleanupServiceImpl.java
new file mode 100644
index 0000000..383b3b1
--- /dev/null
+++ b/src/main/java/org/decepticons/linkshortener/api/service/impl/RevokedTokenCleanupServiceImpl.java
@@ -0,0 +1,40 @@
+package org.decepticons.linkshortener.api.service.impl;
+
+import java.time.Instant;
+import org.decepticons.linkshortener.api.repository.RevokedTokenRepository;
+import org.decepticons.linkshortener.api.service.RevokedTokenCleanupService;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+/**
+ * Service implementation for cleaning up expired revoked tokens.
+ */
+@Service
+public class RevokedTokenCleanupServiceImpl implements
+ RevokedTokenCleanupService {
+
+ /**
+ * The repository for managing revoked tokens.
+ */
+ private final RevokedTokenRepository revokedTokenRepository;
+
+ /**
+ * Constructs a new RevokedTokenCleanupServiceImpl.
+ *
+ * @param revokedTokenRepositoryParam The repository for revoked tokens.
+ */
+ public RevokedTokenCleanupServiceImpl(
+ final RevokedTokenRepository revokedTokenRepositoryParam
+ ) {
+ this.revokedTokenRepository = revokedTokenRepositoryParam;
+ }
+
+ /**
+ * Deletes all expired revoked tokens. This job runs every hour.
+ */
+ @Override
+ @Scheduled(cron = "0 0 * * * ?") // every hour
+ public void cleanupExpiredRevokedTokens() {
+ revokedTokenRepository.deleteAllByExpiresAtBefore(Instant.now());
+ }
+}
diff --git a/src/main/java/org/decepticons/linkshortener/api/service/impl/UserServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/service/impl/UserServiceImpl.java
index d152524..620e05c 100644
--- a/src/main/java/org/decepticons/linkshortener/api/service/impl/UserServiceImpl.java
+++ b/src/main/java/org/decepticons/linkshortener/api/service/impl/UserServiceImpl.java
@@ -1,50 +1,65 @@
-package org.decepticons.linkshortener.api.service.impl;
+ package org.decepticons.linkshortener.api.service.impl;
-import java.util.UUID;
-import org.decepticons.linkshortener.api.exception.NoSuchUserFoundInTheSystemException;
-import org.decepticons.linkshortener.api.model.User;
-import org.decepticons.linkshortener.api.repository.UserRepository;
-import org.decepticons.linkshortener.api.service.UserService;
-import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.stereotype.Service;
-
-/**
- * Service implementation for retrieving information about the currently authenticated user.
- */
-@Service
-public class UserServiceImpl implements UserService {
-
- public UserRepository userRepository;
+ import java.util.UUID;
+ import org.decepticons.linkshortener.api.exception.NoSuchUserFoundInTheSystemException;
+ import org.decepticons.linkshortener.api.exception.UserNotFoundException;
+ import org.decepticons.linkshortener.api.model.User;
+ import org.decepticons.linkshortener.api.repository.UserRepository;
+ import org.decepticons.linkshortener.api.service.UserService;
+ import org.springframework.security.core.context.SecurityContextHolder;
+ import org.springframework.stereotype.Service;
/**
- * Constructs a new UserServiceImpl with the given UserRepository.
- *
- * @param userRepository the repository used to access user data
+ * Service implementation for retrieving information about the currently authenticated user.
*/
- public UserServiceImpl(UserRepository userRepository) {
- this.userRepository = userRepository;
- }
+ @Service
+ public class UserServiceImpl implements UserService {
- @Override
- public UUID getCurrentUserId() {
- String username = SecurityContextHolder.getContext().getAuthentication().getName();
- User user = userRepository.findByUsername(username)
- .orElseThrow(() -> new NoSuchUserFoundInTheSystemException(
- "No such user found in the system: " + username,
- username
- ));
- return user.getId();
+ final public UserRepository userRepository;
- }
+ /**
+ * Constructs a new UserServiceImpl with the given UserRepository.
+ *
+ * @param userRepository the repository used to access user data
+ */
+ public UserServiceImpl(UserRepository userRepository) {
+ this.userRepository = userRepository;
+ }
- @Override
- public User getCurrentUser() {
- String username = SecurityContextHolder.getContext().getAuthentication().getName();
- return userRepository.findByUsername(username)
- .orElseThrow(() -> new NoSuchUserFoundInTheSystemException(
- "No such user found in the system: " + username,
- username
- ));
- }
+ @Override
+ public UUID getCurrentUserId() {
+ String username = SecurityContextHolder.getContext().getAuthentication().getName();
+ User user = userRepository.findByUsername(username)
+ .orElseThrow(() -> new NoSuchUserFoundInTheSystemException(
+ "No such user found in the system: " + username,
+ username
+ ));
+ return user.getId();
-}
+ }
+
+ @Override
+ public User getCurrentUser() {
+ String username = SecurityContextHolder.getContext().getAuthentication().getName();
+ return userRepository.findByUsername(username)
+ .orElseThrow(() -> new NoSuchUserFoundInTheSystemException(
+ "No such user found in the system: " + username,
+ username
+ ));
+ }
+
+ /**
+ * Finds a user by their username.
+ *
+ * @param username the username to search for
+ * @return the User entity
+ * @throws UserNotFoundException if no user is found
+ */
+ @Override
+ public User findByUsername(final String username) {
+ return userRepository.findByUsername(username)
+ .orElseThrow(() -> new UserNotFoundException(username)
+ );
+ }
+
+ }
diff --git a/src/main/java/org/decepticons/linkshortener/api/service/impl/package-info.java b/src/main/java/org/decepticons/linkshortener/api/service/impl/package-info.java
new file mode 100644
index 0000000..b511c10
--- /dev/null
+++ b/src/main/java/org/decepticons/linkshortener/api/service/impl/package-info.java
@@ -0,0 +1,6 @@
+/**
+ * Provides the implementation classes for the application's service layer.
+ * These services contain the core business logic, such as token cleanup
+ * and user management.
+ */
+package org.decepticons.linkshortener.api.service.impl;
\ No newline at end of file
diff --git a/src/main/java/org/decepticons/linkshortener/api/v1/controller/AuthController.java b/src/main/java/org/decepticons/linkshortener/api/v1/controller/AuthController.java
index 17593a0..a3c6832 100644
--- a/src/main/java/org/decepticons/linkshortener/api/v1/controller/AuthController.java
+++ b/src/main/java/org/decepticons/linkshortener/api/v1/controller/AuthController.java
@@ -4,7 +4,6 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
-import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.decepticons.linkshortener.api.dto.AuthRequestDto;
import org.decepticons.linkshortener.api.dto.AuthResponseDto;
@@ -14,6 +13,7 @@
import org.decepticons.linkshortener.api.security.model.CustomUserDetails;
import org.decepticons.linkshortener.api.security.service.AuthService;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -23,7 +23,8 @@
/**
* REST controller for user authentication.
- * Provides endpoints for user registration, login, and token refresh.
+ * Provides endpoints for user registration, login, and
+ * token refresh.
*/
@Tag(
name = "Authentication",
@@ -34,10 +35,14 @@
@RequiredArgsConstructor
public class AuthController {
- /** Service for authentication and user registration operations. */
+ /**
+ * Service for authentication and user registration operations.
+ */
private final AuthService authService;
- /** Utility for generating and validating JWT tokens. */
+ /**
+ * Utility for generating and validating JWT tokens.
+ */
private final JwtTokenUtil jwtUtil;
/**
@@ -70,13 +75,16 @@ public ResponseEntity createUser(
public ResponseEntity authenticate(
@RequestBody final AuthRequestDto request
) {
- User user = authService.login(request.getUsername(), request.getPassword());
+ User user = authService.login(
+ request.getUsername(),
+ request.getPassword()
+ );
UserDetails userDetails = new CustomUserDetails(user);
String accessToken = jwtUtil.generateAccessToken(userDetails);
String refreshToken = jwtUtil.generateRefreshToken(userDetails);
List roles = userDetails.getAuthorities().stream()
- .map(Object::toString)
- .collect(Collectors.toList());
+ .map(GrantedAuthority::getAuthority)
+ .toList();
return ResponseEntity.ok(new AuthResponseDto(
user.getUsername(),
@@ -102,8 +110,8 @@ public ResponseEntity refreshToken(
String newAccessToken = jwtUtil.generateAccessToken(userDetails);
String newRefreshToken = jwtUtil.generateRefreshToken(userDetails);
List roles = userDetails.getAuthorities().stream()
- .map(Object::toString)
- .collect(Collectors.toList());
+ .map(GrantedAuthority::getAuthority)
+ .toList();
return ResponseEntity.ok(new AuthResponseDto(
user.getUsername(),
@@ -112,4 +120,20 @@ public ResponseEntity refreshToken(
newRefreshToken
));
}
+
+ /**
+ * Logs out the user by revoking their token.
+ *
+ * @param authHeader the Authorization header containing
+ * the token to be revoked
+ * @return a success message
+ */
+ @PostMapping("/logout")
+ @Operation(summary = "Logout and revoke token")
+ public ResponseEntity logout(
+ @RequestHeader("Authorization") final String authHeader
+ ) {
+ authService.logout(authHeader);
+ return ResponseEntity.ok("Successfully logged out");
+ }
}
diff --git a/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/GlobalExceptionHandlerController.java b/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/GlobalExceptionHandlerController.java
index 97f0bb5..7d98e68 100644
--- a/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/GlobalExceptionHandlerController.java
+++ b/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/GlobalExceptionHandlerController.java
@@ -162,7 +162,7 @@ public ResponseEntity