From 4783fe9c298bd2b1d0025a27c393b632ce94aa55 Mon Sep 17 00:00:00 2001 From: Hanif Date: Wed, 3 Jan 2024 17:17:42 +0700 Subject: [PATCH] Add Refresh Token API --- .../auth/controllers/AuthController.java | 60 ++++++++++++++--- .../modules/auth/data/RefreshToken.java | 65 +++++++++++++++++++ .../modules/auth/jwt/JwtUtils.java | 50 ++++++++++---- .../jwt/advice/TokenControllerAdvice.java | 26 ++++++++ .../auth/jwt/exception/ErrorMessage.java | 34 ++++++++++ .../jwt/exception/TokenRefreshException.java | 14 ++++ .../repository/RefreshTokenRepository.java | 18 +++++ .../auth/service/RefreshTokenService.java | 57 ++++++++++++++++ src/main/resources/application.properties | 4 +- 9 files changed, 308 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/data/RefreshToken.java create mode 100644 src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/advice/TokenControllerAdvice.java create mode 100644 src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/exception/ErrorMessage.java create mode 100644 src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/exception/TokenRefreshException.java create mode 100644 src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/repository/RefreshTokenRepository.java create mode 100644 src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/service/RefreshTokenService.java diff --git a/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/controllers/AuthController.java b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/controllers/AuthController.java index 802ddc8..4f11f7c 100644 --- a/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/controllers/AuthController.java +++ b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/controllers/AuthController.java @@ -1,6 +1,7 @@ package com.hakimfauzi23.boilerplatespringsecurity.modules.auth.controllers; import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.ERole; +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.RefreshToken; import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.Role; import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.User; import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.payload.request.LoginRequest; @@ -8,9 +9,12 @@ import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.payload.requ import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.payload.response.MessageResponse; import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.payload.response.UserInfoResponse; import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.jwt.JwtUtils; +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.jwt.exception.TokenRefreshException; import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.repository.RoleRepository; import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.repository.UserRepository; +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.service.RefreshTokenService; import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.service.UserDetailsImpl; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; @@ -19,12 +23,14 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -48,6 +54,9 @@ public class AuthController { @Autowired JwtUtils jwtUtils; + @Autowired + RefreshTokenService refreshTokenService; + @PostMapping("/signup") public ResponseEntity registerUser(@Valid @RequestBody SignupRequest signUpRequest) { if (userRepository.existsByUsername(signUpRequest.getUsername())) { @@ -103,20 +112,55 @@ public class AuthController { ResponseCookie jwtCookie = jwtUtils.generateJwtCookie(userDetails); List roles = userDetails.getAuthorities().stream() - .map(item -> item.getAuthority()) + .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); - return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, jwtCookie.toString()) - .body(new UserInfoResponse(userDetails.getId(), - userDetails.getUsername(), - userDetails.getEmail(), - roles)); + RefreshToken refreshToken = refreshTokenService.createRefreshToken(userDetails.getId()); + + ResponseCookie jwtRefreshCookie = jwtUtils.generateRefreshJwtCookie(refreshToken.getToken()); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, jwtCookie.toString()) + .header(HttpHeaders.SET_COOKIE, jwtRefreshCookie.toString()) + .body(new UserInfoResponse(userDetails.getId(), userDetails.getUsername(), userDetails.getEmail(), roles)); } @PostMapping("/signout") public ResponseEntity logoutUser() { - ResponseCookie cookie = jwtUtils.getCleanJwtCookie(); - return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()) + Object principle = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (!Objects.equals(principle.toString(), "anonymousUser")) { + Long userId = ((UserDetailsImpl) principle).getId(); + refreshTokenService.deleteByUserId(userId); + } + + ResponseCookie jwtCookie = jwtUtils.getCleanJwtCookie(); + ResponseCookie jwtRefreshCookie = jwtUtils.getCleanJwtRefreshCookie(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, jwtCookie.toString()) + .header(HttpHeaders.SET_COOKIE, jwtRefreshCookie.toString()) .body(new MessageResponse("You've been signed out!")); } + + @PostMapping("/refresh-token") + public ResponseEntity refreshToken(HttpServletRequest request) { + String refreshToken = jwtUtils.getJwtRefreshFromCookies(request); + + if ((refreshToken != null) && (refreshToken.length() > 0)) { + return refreshTokenService.findByToken(refreshToken) + .map(refreshTokenService::verifyExpiration) + .map(RefreshToken::getUser) + .map(user -> { + ResponseCookie jwtCookie = jwtUtils.generateJwtCookie(user); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, jwtCookie.toString()) + .body(new MessageResponse("Token is refreshed successfully!")); + }) + .orElseThrow(() -> new TokenRefreshException(refreshToken, + "Refresh token is not in database!")); + } + + return ResponseEntity.badRequest().body(new MessageResponse("Refresh Token is empty!")); + } } diff --git a/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/data/RefreshToken.java b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/data/RefreshToken.java new file mode 100644 index 0000000..ac2302a --- /dev/null +++ b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/data/RefreshToken.java @@ -0,0 +1,65 @@ +package com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data; + +import jakarta.persistence.*; + +import java.time.Instant; + +@Entity(name = "refresh_token") +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + @OneToOne + @JoinColumn(name = "user_id", referencedColumnName = "id") + private User user; + + @Column(nullable = false, unique = true) + private String token; + + @Column(nullable = false) + private Instant expirationDate; + + public RefreshToken() { + } + + public RefreshToken(Long id, User user, String token, Instant expirationDate) { + this.id = id; + this.user = user; + this.token = token; + this.expirationDate = expirationDate; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Instant getExpirationDate() { + return expirationDate; + } + + public void setExpirationDate(Instant expirationDate) { + this.expirationDate = expirationDate; + } +} diff --git a/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/JwtUtils.java b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/JwtUtils.java index 99c29dc..029933e 100644 --- a/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/JwtUtils.java +++ b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/JwtUtils.java @@ -1,5 +1,6 @@ package com.hakimfauzi23.boilerplatespringsecurity.modules.auth.jwt; +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.User; import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.service.UserDetailsImpl; import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; @@ -30,26 +31,40 @@ public class JwtUtils { @Value("${spring.app.jwtCookieName}") private String jwtCookie; + @Value("${spring.app.jwtRefreshCookieName}") + private String jwtRefreshCookie; + public String getJwtFromCookies(HttpServletRequest request) { - Cookie cookie = WebUtils.getCookie(request, jwtCookie); - if (cookie != null) { - return cookie.getValue(); - } else { - return null; - } + return getCookieValueByName(request, jwtCookie); + } + + public String getJwtRefreshFromCookies(HttpServletRequest request) { + return getCookieValueByName(request, jwtRefreshCookie); + } + + public ResponseCookie getCleanJwtCookie() { + return ResponseCookie.from(jwtCookie, null).path("/api").build(); + } + + public ResponseCookie getCleanJwtRefreshCookie() { + return ResponseCookie.from(jwtRefreshCookie, null).path("/api/auth/refresh-token").build(); } public ResponseCookie generateJwtCookie(UserDetailsImpl userDetails) { String jwt = generateTokenFromUsername(userDetails.getUsername()); - ResponseCookie cookie = ResponseCookie.from(jwtCookie, jwt).path("/api").maxAge(24 * 60 * 60).httpOnly(true).build(); - return cookie; + return generateCookie(jwtCookie, jwt, "/api"); } - public ResponseCookie getCleanJwtCookie() { - ResponseCookie cookie = ResponseCookie.from(jwtCookie, null).path("/api").build(); - return cookie; + public ResponseCookie generateJwtCookie(User user) { + String jwt = generateTokenFromUsername(user.getUsername()); + return generateCookie(jwtCookie, jwt, "/api"); } + public ResponseCookie generateRefreshJwtCookie(String refreshToken) { + return generateCookie(jwtRefreshCookie, refreshToken, "/api/auth/refresh-token"); + } + + public String getUserNameFromJwtToken(String token) { return Jwts.parserBuilder().setSigningKey(key()).build() .parseClaimsJws(token).getBody().getSubject(); @@ -84,4 +99,17 @@ public class JwtUtils { .signWith(key(), SignatureAlgorithm.HS256) .compact(); } + + private ResponseCookie generateCookie(String name, String value, String path) { + return ResponseCookie.from(name, value).path(path).maxAge(24 * 60 * 60).httpOnly(true).build(); + } + + private String getCookieValueByName(HttpServletRequest request, String name) { + Cookie cookie = WebUtils.getCookie(request, name); + if (cookie != null) { + return cookie.getValue(); + } else { + return null; + } + } } diff --git a/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/advice/TokenControllerAdvice.java b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/advice/TokenControllerAdvice.java new file mode 100644 index 0000000..dddf453 --- /dev/null +++ b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/advice/TokenControllerAdvice.java @@ -0,0 +1,26 @@ +package com.hakimfauzi23.boilerplatespringsecurity.modules.auth.jwt.advice; + +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.jwt.exception.ErrorMessage; +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.jwt.exception.TokenRefreshException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +import java.util.Date; + +@RestControllerAdvice +public class TokenControllerAdvice { + + @ExceptionHandler(value = TokenRefreshException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ErrorMessage handleTokenRefreshException(TokenRefreshException ex, WebRequest request) { + return new ErrorMessage( + HttpStatus.FORBIDDEN.value(), + new Date(), + ex.getMessage(), + request.getDescription(false) + ); + } +} diff --git a/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/exception/ErrorMessage.java b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/exception/ErrorMessage.java new file mode 100644 index 0000000..95524f4 --- /dev/null +++ b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/exception/ErrorMessage.java @@ -0,0 +1,34 @@ +package com.hakimfauzi23.boilerplatespringsecurity.modules.auth.jwt.exception; + +import java.util.Date; + +public class ErrorMessage { + + private int statusCode; + private Date timestamp; + private String message; + private String description; + + public ErrorMessage(int statusCode, Date timestamp, String message, String description) { + this.statusCode = statusCode; + this.timestamp = timestamp; + this.message = message; + this.description = description; + } + + public int getStatusCode() { + return statusCode; + } + + public Date getTimestamp() { + return timestamp; + } + + public String getMessage() { + return message; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/exception/TokenRefreshException.java b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/exception/TokenRefreshException.java new file mode 100644 index 0000000..a91d7ac --- /dev/null +++ b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/jwt/exception/TokenRefreshException.java @@ -0,0 +1,14 @@ +package com.hakimfauzi23.boilerplatespringsecurity.modules.auth.jwt.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.FORBIDDEN) +public class TokenRefreshException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public TokenRefreshException(String token, String message) { + super(String.format("Failed for [%s]: %s", token, message)); + } +} diff --git a/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/repository/RefreshTokenRepository.java b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..904cc59 --- /dev/null +++ b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,18 @@ +package com.hakimfauzi23.boilerplatespringsecurity.modules.auth.repository; + +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.RefreshToken; +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + @Modifying + int deleteByUser(User user); +} diff --git a/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/service/RefreshTokenService.java b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/service/RefreshTokenService.java new file mode 100644 index 0000000..1e2d518 --- /dev/null +++ b/src/main/java/com/hakimfauzi23/boilerplatespringsecurity/modules/auth/service/RefreshTokenService.java @@ -0,0 +1,57 @@ +package com.hakimfauzi23.boilerplatespringsecurity.modules.auth.service; + +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.data.RefreshToken; +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.jwt.exception.TokenRefreshException; +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.repository.RefreshTokenRepository; +import com.hakimfauzi23.boilerplatespringsecurity.modules.auth.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +@Service +public class RefreshTokenService { + + @Value("${spring.app.jwtRefreshExpirationMs}") + private Long refreshTokenDurationMs; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private UserRepository userRepository; + + public Optional findByToken(String token) { + return refreshTokenRepository.findByToken(token); + } + + public RefreshToken createRefreshToken(Long userId) { + RefreshToken refreshToken = new RefreshToken(); + + refreshToken.setUser(userRepository.findById(userId).get()); + refreshToken.setExpirationDate(Instant.now().plusMillis(refreshTokenDurationMs)); + refreshToken.setToken(UUID.randomUUID().toString()); + + refreshToken = refreshTokenRepository.save(refreshToken); + return refreshToken; + } + + public RefreshToken verifyExpiration(RefreshToken token) { + if (token.getExpirationDate().compareTo(Instant.now()) < 0) { + refreshTokenRepository.delete(token); + throw new TokenRefreshException(token.getToken(), "Refresh token was expired!. Please make a new signin request"); + } + + return token; + } + + @Transactional + public int deleteByUserId(Long userId) { + return refreshTokenRepository.deleteByUser(userRepository.findById(userId).get()); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6f027bf..47111f0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,5 +5,7 @@ spring.jpa.hibernate.ddl-auto= update # App Properties spring.app.jwtCookieName= cookie-jwt-for-security +spring.app.jwtRefreshCookieName= cookie-refresh-jwt-for-security spring.app.jwtSecret= 9sL3p2mGzN7oR4Dx8QcY1uKwF5BhVtX6EaJgU3iZqOyMlIbCnAeHrWfPd0 -spring.app.jwtExpirationMs= 86400000 \ No newline at end of file +spring.app.jwtExpirationMs= 86400000 +spring.app.jwtRefreshExpirationMs= 259200000 \ No newline at end of file