diff --git a/src/main/java/com/fredmaina/chatapp/Auth/Dtos/AuthResponse.java b/src/main/java/com/fredmaina/chatapp/Auth/Dtos/AuthResponse.java index e566b24..cf3a0de 100644 --- a/src/main/java/com/fredmaina/chatapp/Auth/Dtos/AuthResponse.java +++ b/src/main/java/com/fredmaina/chatapp/Auth/Dtos/AuthResponse.java @@ -13,9 +13,10 @@ public class AuthResponse { private String message; private User user; private String token; + private String refreshToken; - public AuthResponse(boolean success, String message, User user, String token) { + public AuthResponse(boolean success, String message, User user, String token, String refreshToken) { this.success = success; this.message = message; this.user = user; diff --git a/src/main/java/com/fredmaina/chatapp/Auth/Models/RefreshToken.java b/src/main/java/com/fredmaina/chatapp/Auth/Models/RefreshToken.java new file mode 100644 index 0000000..1f3876c --- /dev/null +++ b/src/main/java/com/fredmaina/chatapp/Auth/Models/RefreshToken.java @@ -0,0 +1,29 @@ +package com.fredmaina.chatapp.Auth.Models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; + +@Entity +@Getter +@Setter +@Table(name = "refresh_tokens") +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Column(nullable = false, unique = true) + private String token; + + @Column(nullable = false) + private Instant expiryDate; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + +} \ No newline at end of file diff --git a/src/main/java/com/fredmaina/chatapp/Auth/Repositories/RefreshRepository.java b/src/main/java/com/fredmaina/chatapp/Auth/Repositories/RefreshRepository.java new file mode 100644 index 0000000..bffccd1 --- /dev/null +++ b/src/main/java/com/fredmaina/chatapp/Auth/Repositories/RefreshRepository.java @@ -0,0 +1,12 @@ +package com.fredmaina.chatapp.Auth.Repositories; + +import com.fredmaina.chatapp.Auth.Models.RefreshToken; +import com.fredmaina.chatapp.Auth.Models.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshRepository extends JpaRepository { + Optional findByToken(String token); + int deleteByUser(User user); +} diff --git a/src/main/java/com/fredmaina/chatapp/Auth/controllers/AuthController.java b/src/main/java/com/fredmaina/chatapp/Auth/controllers/AuthController.java index 33ac32d..7a7b41d 100644 --- a/src/main/java/com/fredmaina/chatapp/Auth/controllers/AuthController.java +++ b/src/main/java/com/fredmaina/chatapp/Auth/controllers/AuthController.java @@ -2,15 +2,21 @@ import com.fredmaina.chatapp.Auth.Dtos.*; +import com.fredmaina.chatapp.Auth.Models.RefreshToken; import com.fredmaina.chatapp.Auth.Models.User; +import com.fredmaina.chatapp.Auth.Repositories.RefreshRepository; import com.fredmaina.chatapp.Auth.Repositories.UserRepository; import com.fredmaina.chatapp.Auth.services.AuthService; import com.fredmaina.chatapp.Auth.services.JWTService; +import com.fredmaina.chatapp.Auth.services.RefreshTokenService; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; @@ -28,17 +34,34 @@ public class AuthController { @Autowired UserRepository userRepository; + @Autowired + RefreshRepository refreshTokenRepository; + + @Autowired + RefreshTokenService refreshTokenService; + @PostMapping("/login") public ResponseEntity login(@RequestBody LoginRequest loginRequest) { AuthResponse authResponse = authService.login(loginRequest); if(authResponse.isSuccess()){ - return ResponseEntity.ok(authResponse); + ResponseCookie cookie = ResponseCookie.from("refreshToken", authResponse.getRefreshToken()) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .path("/api/auth/refresh") + .maxAge(7 * 24 * 60 * 60) + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(authResponse); } return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(authResponse); } + @PostMapping("/register") public ResponseEntity register(@Valid @RequestBody SignUpRequest signUpRequest) { AuthResponse authResponse = authService.signUp(signUpRequest); @@ -128,4 +151,41 @@ public ResponseEntity> checkUsername(@PathVariable String us )); } } -} \ No newline at end of file + @PostMapping("/refresh") + public ResponseEntity refresh(@CookieValue("refreshToken") String refreshToken) { + + RefreshToken rt = refreshTokenRepository.findByToken(refreshToken) + .orElseThrow(() -> new RuntimeException("Invalid refresh token")); + + refreshTokenService.verifyExpiration(rt); + + String newAccessToken = jwtService.generateToken(rt.getUser().getEmail()); + + return ResponseEntity.ok( + AuthResponse.builder() + .success(true) + .message("Token refreshed") + .token(newAccessToken) + .refreshToken(refreshToken) + .user(rt.getUser()) + .build() + ); + } + + @PostMapping("/logout") + public ResponseEntity logout(@AuthenticationPrincipal User user) { + + refreshTokenService.deleteByUser(user); + + ResponseCookie cookie = ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body("Logged out"); + } +} diff --git a/src/main/java/com/fredmaina/chatapp/Auth/services/AuthService.java b/src/main/java/com/fredmaina/chatapp/Auth/services/AuthService.java index 3364ab0..5d281dc 100644 --- a/src/main/java/com/fredmaina/chatapp/Auth/services/AuthService.java +++ b/src/main/java/com/fredmaina/chatapp/Auth/services/AuthService.java @@ -5,6 +5,7 @@ import com.fredmaina.chatapp.Auth.Dtos.GoogleOAuthRequest; import com.fredmaina.chatapp.Auth.Dtos.LoginRequest; import com.fredmaina.chatapp.Auth.Dtos.SignUpRequest; +import com.fredmaina.chatapp.Auth.Models.RefreshToken; import com.fredmaina.chatapp.Auth.Models.Role; import com.fredmaina.chatapp.Auth.Models.User; import com.fredmaina.chatapp.Auth.Repositories.UserRepository; @@ -26,41 +27,59 @@ import java.util.Map; import java.util.Optional; + +import org.springframework.http.*; + @Service @Slf4j public class AuthService { - final UserRepository userRepository; - final PasswordEncoder passwordEncoder; - final JWTService jwtService; - final GoogleOAuthProperties googleOAuthProperties; - final RestTemplate restTemplate; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JWTService jwtService; + private final GoogleOAuthProperties googleOAuthProperties; + private final RestTemplate restTemplate; + private final RefreshTokenService refreshTokenService; @Autowired - AuthService (UserRepository userRepository, PasswordEncoder passwordEncoder, GoogleOAuthProperties googleOAuthProperties,RestTemplate restTemplate,JWTService jwtService) { + AuthService( + UserRepository userRepository, + PasswordEncoder passwordEncoder, + GoogleOAuthProperties googleOAuthProperties, + RestTemplate restTemplate, + JWTService jwtService, + RefreshTokenService refreshTokenService + ) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.googleOAuthProperties = googleOAuthProperties; this.restTemplate = restTemplate; this.jwtService = jwtService; + this.refreshTokenService = refreshTokenService; } + // --------------------------------------------------------------------------- + // SIGN UP + // --------------------------------------------------------------------------- + @CacheEvict(value = "usernameCheck", key = "#request.username.toLowerCase()") public AuthResponse signUp(SignUpRequest request) { AuthResponse authResponse = new AuthResponse(); - if (request.getUsername() != null && userRepository.findByUsernameIgnoreCase(request.getUsername()).isPresent()) { + + if (request.getUsername() != null && + userRepository.findByUsernameIgnoreCase(request.getUsername()).isPresent()) { authResponse.setMessage("Username already exists (case-insensitive)"); authResponse.setSuccess(false); return authResponse; } + if (userRepository.findByEmail(request.getEmail()).isPresent()) { authResponse.setMessage("Email already exists"); authResponse.setSuccess(false); return authResponse; } - User user = new User(); user.setUsername(request.getUsername()); user.setPassword(passwordEncoder.encode(request.getPassword())); @@ -73,22 +92,29 @@ public AuthResponse signUp(SignUpRequest request) { try { userRepository.save(user); - String token = jwtService.generateToken(user.getEmail()); + // Generate tokens + String accessToken = jwtService.generateToken(user.getEmail()); + RefreshToken refreshToken = refreshTokenService.createRefreshToken(user); authResponse.setMessage("User registered and logged in successfully"); authResponse.setSuccess(true); authResponse.setUser(user); - authResponse.setToken(token); + authResponse.setToken(accessToken); + authResponse.setRefreshToken(refreshToken.getToken()); + } catch (DataIntegrityViolationException e) { + log.error("Data integrity violation during sign up: {}", e.getMessage()); + if (e.getMessage().contains("users_email_key")) { authResponse.setMessage("Email already exists"); - } else if (e.getMessage().contains("idx_user_username") || e.getMessage().contains("users_username_key")) { + } else if (e.getMessage().contains("idx_user_username") + || e.getMessage().contains("users_username_key")) { authResponse.setMessage("Username already exists"); } else { - log.error("Data integrity violation during sign up: {}", e.getMessage()); authResponse.setMessage("Data integrity violation"); } authResponse.setSuccess(false); + } catch (Exception e) { log.error("Unexpected error during sign up: {}", e.getMessage(), e); authResponse.setMessage("Unexpected error occurred"); @@ -98,6 +124,11 @@ public AuthResponse signUp(SignUpRequest request) { return authResponse; } + + // --------------------------------------------------------------------------- + // LOGIN + // --------------------------------------------------------------------------- + public AuthResponse login(LoginRequest loginRequest) { AuthResponse authResponse = new AuthResponse(); @@ -108,39 +139,41 @@ public AuthResponse login(LoginRequest loginRequest) { return authResponse; } - User user = null; + User user = userRepository.findByEmail(input) + .or(() -> userRepository.findByUsernameIgnoreCase(input)) + .orElse(null); - Optional byEmail = userRepository.findByEmail(input); - if (byEmail.isPresent()) { - user = byEmail.get(); - } else { - Optional byUsername = userRepository.findByUsernameIgnoreCase(input); - if (byUsername.isPresent()) { - user = byUsername.get(); - } - } - - if (user == null || user.getPassword() == null || + if (user == null || + user.getPassword() == null || !passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { + authResponse.setSuccess(false); authResponse.setMessage("Invalid username/email or password"); return authResponse; } - String token = jwtService.generateToken(user.getEmail()); + String accessToken = jwtService.generateToken(user.getEmail()); + RefreshToken refreshToken = refreshTokenService.createRefreshToken(user); authResponse.setSuccess(true); authResponse.setMessage("Login successful"); authResponse.setUser(user); - authResponse.setToken(token); + authResponse.setToken(accessToken); + authResponse.setRefreshToken(refreshToken.getToken()); + return authResponse; } + // --------------------------------------------------------------------------- + // GOOGLE OAUTH + // --------------------------------------------------------------------------- + public AuthResponse handleGoogleOAuth(String code, String redirectUri) { try { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap params = new LinkedMultiValueMap<>(); params.add("code", code); params.add("client_id", googleOAuthProperties.getClientId()); @@ -148,49 +181,43 @@ public AuthResponse handleGoogleOAuth(String code, String redirectUri) { params.add("redirect_uri", redirectUri); params.add("grant_type", "authorization_code"); - log.info("Sending token request with params: {}", params); + HttpEntity> tokenRequest = + new HttpEntity<>(params, headers); - HttpEntity> tokenRequest = new HttpEntity<>(params, headers); + ResponseEntity tokenResponse = + restTemplate.postForEntity("https://oauth2.googleapis.com/token", + tokenRequest, Map.class); - ResponseEntity tokenResponse = restTemplate.postForEntity( - "https://oauth2.googleapis.com/token", tokenRequest, Map.class); - - log.info("Token response status: {}", tokenResponse.getStatusCode()); - log.debug("Token response body: {}", tokenResponse.getBody()); - - if (tokenResponse.getStatusCode() != HttpStatus.OK || tokenResponse.getBody() == null) { - log.error("Failed to retrieve token: HTTP {} - Body: {}", tokenResponse.getStatusCode(), tokenResponse.getBody()); - return new AuthResponse(false, "OAuth failed: Invalid token response", null, null); + if (!tokenResponse.getStatusCode().is2xxSuccessful() + || tokenResponse.getBody() == null) { + return new AuthResponse(false, "OAuth failed: Invalid token response", null, null,null); } String idToken = (String) tokenResponse.getBody().get("id_token"); if (idToken == null) { - log.error("No id_token found in token response"); - return new AuthResponse(false, "OAuth failed: Missing id_token", null, null); + return new AuthResponse(false, "OAuth failed: Missing id_token", null, null,null); } - String tokenInfoUrl = "https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken; - log.info("Validating id_token via: {}", tokenInfoUrl); - - ResponseEntity tokenInfo = restTemplate.getForEntity(tokenInfoUrl, Map.class); + // Validate token + String tokenInfoUrl = + "https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken; - log.info("Token info response status: {}", tokenInfo.getStatusCode()); - log.debug("Token info response body: {}", tokenInfo.getBody()); + ResponseEntity tokenInfo = + restTemplate.getForEntity(tokenInfoUrl, Map.class); - if (tokenInfo.getStatusCode() != HttpStatus.OK || tokenInfo.getBody() == null) { - log.error("Token info validation failed: HTTP {} - Body: {}", tokenInfo.getStatusCode(), tokenInfo.getBody()); - return new AuthResponse(false, "OAuth failed: Invalid token info", null, null); + if (!tokenInfo.getStatusCode().is2xxSuccessful() + || tokenInfo.getBody() == null) { + return new AuthResponse(false, "OAuth failed: Invalid token info", null, null,null); } Map body = tokenInfo.getBody(); - assert body != null; + String email = (String) body.get("email"); String firstName = (String) body.get("given_name"); String lastName = (String) body.get("family_name"); - Optional existingUser = userRepository.findByEmail(email); - User user = existingUser.orElseGet(() -> { + User user = userRepository.findByEmail(email).orElseGet(() -> { User newUser = new User(); newUser.setEmail(email); newUser.setFirstName(firstName); @@ -200,12 +227,15 @@ public AuthResponse handleGoogleOAuth(String code, String redirectUri) { return userRepository.save(newUser); }); - String token = jwtService.generateToken(user.getEmail()); + // Generate tokens + String accessToken = jwtService.generateToken(user.getEmail()); + RefreshToken refreshToken = refreshTokenService.createRefreshToken(user); return AuthResponse.builder() .success(true) .message("OAuth login successful") - .token(token) + .token(accessToken) + .refreshToken(refreshToken.getToken()) .user(user) .build(); @@ -218,47 +248,56 @@ public AuthResponse handleGoogleOAuth(String code, String redirectUri) { } } + + // --------------------------------------------------------------------------- + // USERNAME UPDATE + // --------------------------------------------------------------------------- + @CacheEvict(value = "usernameCheck", key = "#username.toLowerCase()") public AuthResponse setUsername(String email, String username) { + Optional userByUsername = userRepository.findByUsernameIgnoreCase(username); - if (userByUsername.isPresent() && !userByUsername.get().getEmail().equalsIgnoreCase(email)) { + if (userByUsername.isPresent() && + !userByUsername.get().getEmail().equalsIgnoreCase(email)) { return AuthResponse.builder() .message("Username already taken (case-insensitive)") .success(false) .build(); } - Optional existingUserByEmail = userRepository.findByEmail(email); - if (existingUserByEmail.isPresent()) { - User user = existingUserByEmail.get(); - user.setUsername(username); - try { - userRepository.save(user); - return AuthResponse.builder() - .success(true) - .user(user) - .message("Username set successfully") - .build(); - } catch (DataIntegrityViolationException e) { - - log.error("Data integrity violation while setting username for email {}: {}", email, e.getMessage()); - return AuthResponse.builder() - .message("Username already taken or another data issue occurred.") - .success(false) - .build(); - } + Optional existing = userRepository.findByEmail(email); + if (existing.isEmpty()) { + return AuthResponse.builder() + .message("Invalid email, user not found.") + .success(false) + .build(); + } + + User user = existing.get(); + user.setUsername(username); + + try { + userRepository.save(user); + return AuthResponse.builder() + .success(true) + .user(user) + .message("Username set successfully") + .build(); + + } catch (DataIntegrityViolationException e) { + log.error("Data integrity violation while setting username: {}", e.getMessage()); + return AuthResponse.builder() + .message("Username already taken or another data issue occurred.") + .success(false) + .build(); } - return AuthResponse.builder() - .message("Invalid email, user not found.") - .success(false) - .build(); } + + @Cacheable(value = "usernameCheck", key = "#username != null ? #username.toLowerCase() : 'null'") public Boolean checkUsernameExists(String username) { - log.info("checking if username: {} exists",username); + log.info("checking if username: {} exists", username); if (username == null) return false; return userRepository.existsByUsernameIgnoreCase(username); } - - } diff --git a/src/main/java/com/fredmaina/chatapp/Auth/services/JWTService.java b/src/main/java/com/fredmaina/chatapp/Auth/services/JWTService.java index 455db79..397de04 100644 --- a/src/main/java/com/fredmaina/chatapp/Auth/services/JWTService.java +++ b/src/main/java/com/fredmaina/chatapp/Auth/services/JWTService.java @@ -21,7 +21,7 @@ public class JWTService { @Value("${jwt.secret}") private String secret; - @Value("${jwt.expiration}") + @Value("${jwt.expiration-ms}") private long expiration; private SecretKey key; diff --git a/src/main/java/com/fredmaina/chatapp/Auth/services/RefreshTokenService.java b/src/main/java/com/fredmaina/chatapp/Auth/services/RefreshTokenService.java new file mode 100644 index 0000000..bb181ce --- /dev/null +++ b/src/main/java/com/fredmaina/chatapp/Auth/services/RefreshTokenService.java @@ -0,0 +1,50 @@ +package com.fredmaina.chatapp.Auth.services; + +import com.fredmaina.chatapp.Auth.Models.RefreshToken; +import com.fredmaina.chatapp.Auth.Models.User; +import com.fredmaina.chatapp.Auth.Repositories.RefreshRepository; +import com.fredmaina.chatapp.Auth.Repositories.UserRepository; +import org.springframework.beans.factory.annotation.Value; + +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.UUID; + +@Service +public class RefreshTokenService { + + @Value("${jwt.refresh-expiration-ms}") + private Long refreshTokenDurationMs; + + private final RefreshRepository refreshTokenRepository; + private final UserRepository userRepository; + + public RefreshTokenService(RefreshRepository repo, UserRepository userRepo) { + this.refreshTokenRepository = repo; + this.userRepository = userRepo; + } + + public RefreshToken createRefreshToken(User user) { + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setUser(user); + refreshToken.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs)); + refreshToken.setToken(UUID.randomUUID().toString()); + + return refreshTokenRepository.save(refreshToken); + } + + public RefreshToken verifyExpiration(RefreshToken token) { + if (token.getExpiryDate().isBefore(Instant.now())) { + refreshTokenRepository.delete(token); + throw new RuntimeException("Refresh token expired"); + } + return token; + } + + public void deleteByUser(User user) { + refreshTokenRepository.deleteByUser(user); + } +} + + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3e9c0a8..048462a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,7 +9,9 @@ spring.datasource.driver-class-name=org.postgresql.Driver jwt.secret=${JWT_SECRET} -jwt.expiration=86400000 +jwt.expiration-ms=900000 # 15 minutes +jwt.refresh-expiration-ms=604800000 # 7 days + security.allowed-origins=\ http://localhost:3000,\