diff --git a/src/main/java/com/podzilla/auth/controller/AdminController.java b/src/main/java/com/podzilla/auth/controller/AdminController.java index 2f91bff..29915ad 100644 --- a/src/main/java/com/podzilla/auth/controller/AdminController.java +++ b/src/main/java/com/podzilla/auth/controller/AdminController.java @@ -2,6 +2,8 @@ import com.podzilla.auth.model.User; import com.podzilla.auth.service.AdminService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -19,6 +21,10 @@ public AdminController(final AdminService adminService) { } @GetMapping("/users") + @Operation(summary = "Get all users", + description = "Fetches all users in the system.") + @ApiResponse(responseCode = "200", + description = "Users fetched successfully") public List getUsers() { return adminService.getUsers(); } diff --git a/src/main/java/com/podzilla/auth/controller/AuthenticationController.java b/src/main/java/com/podzilla/auth/controller/AuthenticationController.java index 7f23f24..f441f96 100644 --- a/src/main/java/com/podzilla/auth/controller/AuthenticationController.java +++ b/src/main/java/com/podzilla/auth/controller/AuthenticationController.java @@ -3,6 +3,8 @@ import com.podzilla.auth.dto.LoginRequest; import com.podzilla.auth.dto.SignupRequest; import com.podzilla.auth.service.AuthenticationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; @@ -10,7 +12,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -22,9 +23,6 @@ public class AuthenticationController { private final AuthenticationService authenticationService; - private final SecurityContextLogoutHandler logoutHandler = - new SecurityContextLogoutHandler(); - private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticationController.class); @@ -35,59 +33,72 @@ public AuthenticationController( } @PostMapping("/login") + @Operation( + summary = "Login", + description = "Logs in a user and generates JWT tokens." + ) + @ApiResponse( + responseCode = "200", + description = "User logged in successfully" + ) public ResponseEntity login( @RequestBody final LoginRequest loginRequest, final HttpServletResponse response) { - try { - String email = authenticationService.login(loginRequest, response); - LOGGER.info("User {} logged in", email); - return new ResponseEntity<>( - "User " + email + " logged in successfully", - HttpStatus.OK); - } catch (Exception e) { - LOGGER.error(e.getMessage()); - return new ResponseEntity<>(e.getMessage(), - HttpStatus.UNAUTHORIZED); - } + String email = authenticationService.login(loginRequest, response); + LOGGER.info("User {} logged in", email); + return new ResponseEntity<>( + "User " + email + " logged in successfully", + HttpStatus.OK); } @PostMapping("/register") + @Operation( + summary = "Register", + description = "Registers a new user." + ) + @ApiResponse( + responseCode = "201", + description = "User registered successfully" + ) public ResponseEntity registerUser( - @RequestBody final SignupRequest signupRequest, - final HttpServletRequest request) { - try { - authenticationService.registerAccount(signupRequest); - LOGGER.info("User {} registered", signupRequest.getEmail()); - return new ResponseEntity<>("Account registered.", - HttpStatus.CREATED); - } catch (Exception e) { - LOGGER.error(e.getMessage()); - return new ResponseEntity<>(e.getMessage(), - HttpStatus.UNAUTHORIZED); - } + @RequestBody final SignupRequest signupRequest) { + authenticationService.registerAccount(signupRequest); + LOGGER.info("User {} registered", signupRequest.getEmail()); + return new ResponseEntity<>("Account registered.", + HttpStatus.CREATED); } @PostMapping("/logout") + @Operation( + summary = "Logout", + description = "Logs out a user and invalidates JWT tokens." + ) + @ApiResponse( + responseCode = "200", + description = "User logged out successfully" + ) public ResponseEntity logoutUser(final HttpServletResponse response) { authenticationService.logoutUser(response); return new ResponseEntity<>("You've been signed out!", HttpStatus.OK); } @PostMapping("/refresh-token") + @Operation( + summary = "Refresh Token", + description = "Refreshes the JWT tokens for a logged-in user." + ) + @ApiResponse( + responseCode = "200", + description = "Token refreshed successfully" + ) public ResponseEntity refreshToken( final HttpServletRequest request, final HttpServletResponse response) { - try { - String email = authenticationService.refreshToken( - request, response); - LOGGER.info("User {} refreshed token", email); - return new ResponseEntity<>( - "User " + email + " refreshed token successfully", - HttpStatus.OK); - } catch (Exception e) { - LOGGER.error(e.getMessage()); - return new ResponseEntity<>(e.getMessage(), - HttpStatus.UNAUTHORIZED); - } + String email = authenticationService.refreshToken( + request, response); + LOGGER.info("User {} refreshed token", email); + return new ResponseEntity<>( + "User " + email + " refreshed tokens successfully", + HttpStatus.OK); } } diff --git a/src/main/java/com/podzilla/auth/controller/ResourceController.java b/src/main/java/com/podzilla/auth/controller/ResourceController.java deleted file mode 100644 index 62c464a..0000000 --- a/src/main/java/com/podzilla/auth/controller/ResourceController.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.podzilla.auth.controller; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class ResourceController { - - @GetMapping("/secret_resource") - public ResponseEntity secret() { - return new ResponseEntity<>( - "You are viewing my secret", HttpStatus.OK); - } - - @GetMapping("/public_resource") - public ResponseEntity noSecret() { - // assuming no existing user - - return new ResponseEntity<>("You are in public area", HttpStatus.OK); - } -} diff --git a/src/main/java/com/podzilla/auth/exception/ErrorResponse.java b/src/main/java/com/podzilla/auth/exception/ErrorResponse.java new file mode 100644 index 0000000..f5fc0e8 --- /dev/null +++ b/src/main/java/com/podzilla/auth/exception/ErrorResponse.java @@ -0,0 +1,19 @@ +package com.podzilla.auth.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import java.time.LocalDateTime; + +@Getter +public class ErrorResponse { + private final String message; + private final HttpStatus status; + private final LocalDateTime timestamp; + + public ErrorResponse(final String message, final HttpStatus status) { + this.message = message; + this.status = status; + this.timestamp = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/podzilla/auth/exception/GlobalExceptionHandler.java b/src/main/java/com/podzilla/auth/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..527126e --- /dev/null +++ b/src/main/java/com/podzilla/auth/exception/GlobalExceptionHandler.java @@ -0,0 +1,42 @@ +package com.podzilla.auth.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFoundException( + final NotFoundException exception) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.NOT_FOUND)); + } + + @ExceptionHandler(ValidationException.class) + public ResponseEntity handleValidationException( + final ValidationException exception) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.BAD_REQUEST)); + } + + @ExceptionHandler(InvalidActionException.class) + public ResponseEntity handleInvalidActionException( + final InvalidActionException exception) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.BAD_REQUEST)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException( + final Exception exception) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR)); + } +} diff --git a/src/main/java/com/podzilla/auth/exception/InvalidActionException.java b/src/main/java/com/podzilla/auth/exception/InvalidActionException.java new file mode 100644 index 0000000..b9ed654 --- /dev/null +++ b/src/main/java/com/podzilla/auth/exception/InvalidActionException.java @@ -0,0 +1,7 @@ +package com.podzilla.auth.exception; + +public class InvalidActionException extends RuntimeException { + public InvalidActionException(final String message) { + super("Invalid action: " + message); + } +} diff --git a/src/main/java/com/podzilla/auth/exception/NotFoundException.java b/src/main/java/com/podzilla/auth/exception/NotFoundException.java new file mode 100644 index 0000000..bd16723 --- /dev/null +++ b/src/main/java/com/podzilla/auth/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package com.podzilla.auth.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(final String message) { + super("Not Found: " + message); + } +} diff --git a/src/main/java/com/podzilla/auth/exception/ValidationException.java b/src/main/java/com/podzilla/auth/exception/ValidationException.java new file mode 100644 index 0000000..58df19a --- /dev/null +++ b/src/main/java/com/podzilla/auth/exception/ValidationException.java @@ -0,0 +1,7 @@ +package com.podzilla.auth.exception; + +public class ValidationException extends RuntimeException { + public ValidationException(final String message) { + super("Validation error: " + message); + } +} diff --git a/src/main/java/com/podzilla/auth/security/SecurityConfig.java b/src/main/java/com/podzilla/auth/security/SecurityConfig.java index 11e868e..d8b83f0 100644 --- a/src/main/java/com/podzilla/auth/security/SecurityConfig.java +++ b/src/main/java/com/podzilla/auth/security/SecurityConfig.java @@ -47,6 +47,8 @@ SecurityFilterChain securityFilterChain(final HttpSecurity http) .requestMatchers("/auth/**").permitAll() .requestMatchers("/admin/**") .hasRole("ADMIN") + .requestMatchers("/swagger-ui/**", + "/v3/api-docs/**").permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/podzilla/auth/service/AuthenticationService.java b/src/main/java/com/podzilla/auth/service/AuthenticationService.java index db361b5..406dd52 100644 --- a/src/main/java/com/podzilla/auth/service/AuthenticationService.java +++ b/src/main/java/com/podzilla/auth/service/AuthenticationService.java @@ -2,12 +2,12 @@ import com.podzilla.auth.dto.LoginRequest; import com.podzilla.auth.dto.SignupRequest; +import com.podzilla.auth.exception.ValidationException; import com.podzilla.auth.model.ERole; import com.podzilla.auth.model.Role; import com.podzilla.auth.model.User; import com.podzilla.auth.repository.RoleRepository; import com.podzilla.auth.repository.UserRepository; -import jakarta.persistence.EntityExistsException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.AuthenticationManager; @@ -66,7 +66,7 @@ public String login(final LoginRequest loginRequest, public void registerAccount(final SignupRequest signupRequest) { if (userRepository.existsByEmail(signupRequest.getEmail())) { - throw new EntityExistsException("Email already used"); + throw new ValidationException("Email already in use."); } User account = new User( @@ -92,7 +92,7 @@ public String refreshToken(final HttpServletRequest request, tokenService.generateAccessToken(email, response); return email; } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid refresh token"); + throw new ValidationException("Invalid refresh token."); } } } diff --git a/src/main/java/com/podzilla/auth/service/CustomUserDetailsService.java b/src/main/java/com/podzilla/auth/service/CustomUserDetailsService.java index 5e6da6e..aa23490 100644 --- a/src/main/java/com/podzilla/auth/service/CustomUserDetailsService.java +++ b/src/main/java/com/podzilla/auth/service/CustomUserDetailsService.java @@ -1,5 +1,6 @@ package com.podzilla.auth.service; +import com.podzilla.auth.exception.NotFoundException; import com.podzilla.auth.model.User; import com.podzilla.auth.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; @@ -7,7 +8,6 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.Set; @@ -24,11 +24,10 @@ public CustomUserDetailsService(final UserRepository userRepository) { } @Override - public UserDetails loadUserByUsername(final String email) - throws UsernameNotFoundException { + public UserDetails loadUserByUsername(final String email) { User user = userRepository.findByEmail(email) .orElseThrow(() -> - new UsernameNotFoundException( + new NotFoundException( email + " not found.")); Set authorities = user diff --git a/src/main/java/com/podzilla/auth/service/JWTService.java b/src/main/java/com/podzilla/auth/service/JWTService.java new file mode 100644 index 0000000..e8577dc --- /dev/null +++ b/src/main/java/com/podzilla/auth/service/JWTService.java @@ -0,0 +1,185 @@ +package com.podzilla.auth.service; + +import com.podzilla.auth.exception.ValidationException; +import com.podzilla.auth.model.RefreshToken; +import com.podzilla.auth.model.User; +import com.podzilla.auth.repository.RefreshTokenRepository; +import com.podzilla.auth.repository.UserRepository; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.util.WebUtils; + +import javax.crypto.SecretKey; +import java.time.Instant; +import java.time.temporal.TemporalAmount; +import java.util.Date; +import java.util.UUID; + +@Service +public class JWTService { + + // set in .env + @Value("${jwt.token.secret}") + private String secret; + + @Value("${jwt.token.expires}") + private Long jwtExpiresMinutes; + + private Claims claims; + + private static final Integer ACCESS_TOKEN_EXPIRATION_TIME = 60 * 1000; + private static final Integer ACCESS_TOKEN_COOKIE_EXPIRATION_TIME = 60 * 30; + private static final TemporalAmount REFRESH_TOKEN_EXPIRATION_TIME = + java.time.Duration.ofDays(10); + private static final Integer REFRESH_TOKEN_COOKIE_EXPIRATION_TIME = + 60 * 60 * 24 * 10; + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + + public JWTService(final UserRepository userRepository, + final RefreshTokenRepository refreshTokenRepository) { + this.userRepository = userRepository; + this.refreshTokenRepository = refreshTokenRepository; + } + + public void generateAccessToken(final String email, + final HttpServletResponse response) { + String jwt = Jwts.builder() + .subject(email) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + + jwtExpiresMinutes * ACCESS_TOKEN_EXPIRATION_TIME)) + .signWith(getSignInKey()) + .compact(); + + Cookie cookie = new Cookie("accessToken", jwt); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/"); + cookie.setMaxAge(ACCESS_TOKEN_COOKIE_EXPIRATION_TIME); + response.addCookie(cookie); + } + + public void generateRefreshToken(final String email, + final HttpServletResponse response) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new ValidationException("User not found")); + RefreshToken userRefreshToken = + refreshTokenRepository.findByUserIdAndExpiresAtAfter( + user.getId(), Instant.now()).orElse(null); + + if (userRefreshToken == null) { + userRefreshToken = new RefreshToken(); + userRefreshToken.setUser(user); + userRefreshToken.setCreatedAt(Instant.now()); + userRefreshToken.setExpiresAt(Instant.now().plus( + REFRESH_TOKEN_EXPIRATION_TIME)); + refreshTokenRepository.save(userRefreshToken); + } + + String refreshTokenString = userRefreshToken.getId().toString(); + addRefreshTokenToCookie(refreshTokenString, response); + } + + public String renewRefreshToken(final String refreshToken, + final HttpServletResponse response) { + RefreshToken token = + refreshTokenRepository + .findByIdAndExpiresAtAfter( + UUID.fromString(refreshToken), Instant.now()) + .orElseThrow(() -> + new ValidationException( + "Invalid refresh token")); + + token.setExpiresAt(Instant.now()); + refreshTokenRepository.save(token); + + RefreshToken newRefreshToken = new RefreshToken(); + newRefreshToken.setUser(token.getUser()); + newRefreshToken.setCreatedAt(Instant.now()); + newRefreshToken.setExpiresAt(Instant.now().plus( + REFRESH_TOKEN_EXPIRATION_TIME)); + refreshTokenRepository.save(newRefreshToken); + + String newRefreshTokenString = newRefreshToken.getId().toString(); + addRefreshTokenToCookie(newRefreshTokenString, response); + + return token.getUser().getEmail(); + } + + private void addRefreshTokenToCookie(final String refreshToken, + final HttpServletResponse response) { + Cookie cookie = new Cookie("refreshToken", refreshToken); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/api/auth/refresh-token"); + cookie.setMaxAge(REFRESH_TOKEN_COOKIE_EXPIRATION_TIME); + response.addCookie(cookie); + } + + public String getAccessTokenFromCookie(final HttpServletRequest request) { + Cookie cookie = WebUtils.getCookie(request, "accessToken"); + if (cookie != null) { + return cookie.getValue(); + } + return null; + + } + + public String getRefreshTokenFromCookie(final HttpServletRequest request) { + Cookie cookie = WebUtils.getCookie(request, "refreshToken"); + if (cookie != null) { + return cookie.getValue(); + } + return null; + } + + public void validateAccessToken(final String token) { + try { + claims = Jwts.parser() + .verifyWith(getSignInKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + + + } catch (JwtException e) { + throw new ValidationException(e.getMessage()); + } + } + + public void removeAccessTokenFromCookie( + final HttpServletResponse response) { + Cookie cookie = new Cookie("JWT", null); + cookie.setPath("/"); + + response.addCookie(cookie); + } + + public void removeRefreshTokenFromCookie( + final HttpServletResponse response) { + Cookie cookie = new Cookie("refreshToken", null); + cookie.setPath("/api/auth/refresh-token"); + + response.addCookie(cookie); + } + + private SecretKey getSignInKey() { + byte[] keyBytes = Decoders.BASE64.decode(this.secret); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String extractEmail() { + return claims.getSubject(); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e9d1807..be2b6ed 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,9 @@ spring.application.name=auth +# SpringDoc OpenAPI configuration, uncomment on production +#springdoc.api-docs.enabled=false +#springdoc.swagger-ui.enabled=false + logging.file.name=./logs/app.log logging.level.root=info logging.level.com.podzilla.auth=debug