From ed8e4881d643815d8d1b187ddd5ac49be2dc587c Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Thu, 15 Oct 2026 20:42:23 +0100 Subject: [PATCH] implement logout functions and refresh endpoint --- .../vodsystem/controllers/UserController.java | 85 ++++++++++++------- .../ddf/vodsystem/security/TokenService.java | 22 ++++- .../ddf/vodsystem/services/UserService.java | 4 + 3 files changed, 81 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index d776076..a8bd579 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/UserController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/UserController.java @@ -3,16 +3,18 @@ package com.ddf.vodsystem.controllers; import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.controllers.dto.Token; import com.ddf.vodsystem.dto.TokenPackage; +import com.ddf.vodsystem.dto.properties.AuthProperties; import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.services.UserService; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletRequest; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.Duration; import java.util.Optional; @RestController @@ -20,12 +22,18 @@ import java.util.Optional; public class UserController { private final UserService userService; - @Value("${app.security.expiration.jwt}") - private long jwtExpiration; + private final Duration jwtExpiration; + private final Duration refreshTokenExpiration; private static final String SUCCESS = "success"; - public UserController(UserService userService) { + private static final String ACCESS_COOKIE_PATH = "/"; + private static final String REFRESH_COOKIE_PATH = "/api/v1/users"; + + public UserController(UserService userService, + AuthProperties props) { this.userService = userService; + this.jwtExpiration = props.expiration().jwt(); + this.refreshTokenExpiration = props.expiration().refreshToken(); } /** @@ -59,48 +67,67 @@ public class UserController { * @return {@code 200 OK} wrapping the issued JWT as a {@link Token} */ @PostMapping("/login") - public ResponseEntity> login(@RequestBody Token token, + public ResponseEntity> login(@RequestBody Token token, HttpServletRequest request, HttpServletResponse response) { TokenPackage tokenPackage = userService.login(token.token()); - - ResponseCookie cookie = ResponseCookie.from("token", tokenPackage.accessToken()) - .httpOnly(true) - .maxAge(jwtExpiration / 1000) - .sameSite(request.isSecure() ? "None" : "Lax") - .secure(request.isSecure()) - .path("/") - .build(); - - response.addHeader("Set-Cookie", cookie.toString()); + addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken()); return ResponseEntity.ok( - new APIResponse<>(SUCCESS, "Logged in successfully", tokenPackage) + new APIResponse<>(SUCCESS, "Logged in successfully", null) + ); + } + + @PostMapping("/refresh") + public ResponseEntity> refresh( + @RequestBody Token token, + HttpServletResponse response, + HttpServletRequest request) { + TokenPackage tokenPackage = userService.refresh(token.token()); + addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken()); + + return ResponseEntity.ok( + new APIResponse<>(SUCCESS, "Refreshed tokens successfully", null) ); } /** * Logs the user out by clearing the session cookie. * - * @param response the response the expired cookie is written to - * @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags * @return {@code 200 OK} confirming logout */ @PostMapping("/logout") - public ResponseEntity> logout(HttpServletResponse response, - HttpServletRequest request) { - ResponseCookie cookie = ResponseCookie.from("token", "") - .httpOnly(true) - .maxAge(0) - .sameSite(request.isSecure() ? "None" : "Lax") - .secure(request.isSecure()) - .path("/") - .build(); - - response.addHeader("Set-Cookie", cookie.toString()); + public ResponseEntity> logout( + @CookieValue(name = "refreshToken", required = false) String refreshToken) { + userService.logout(refreshToken); return ResponseEntity.ok( new APIResponse<>(SUCCESS, "Logged out successfully", null) ); } + + private void addCookies( + HttpServletRequest request, + HttpServletResponse response, + String accessToken, + String refreshToken) { + ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", accessToken) + .httpOnly(true) + .maxAge(jwtExpiration.getSeconds()) + .sameSite("Lax") + .secure(request.isSecure()) + .path(ACCESS_COOKIE_PATH) + .build(); + + ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .maxAge(refreshTokenExpiration.getSeconds()) + .sameSite("Strict") + .secure(request.isSecure()) + .path(REFRESH_COOKIE_PATH) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()); + } } diff --git a/src/main/java/com/ddf/vodsystem/security/TokenService.java b/src/main/java/com/ddf/vodsystem/security/TokenService.java index fbc68c3..222a134 100644 --- a/src/main/java/com/ddf/vodsystem/security/TokenService.java +++ b/src/main/java/com/ddf/vodsystem/security/TokenService.java @@ -17,6 +17,7 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.HexFormat; +import java.util.Optional; @Service public class TokenService { @@ -123,6 +124,26 @@ public class TokenService { return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken)); } + public void revoke(String hexToken) { + Instant now = Instant.now(clock); + + byte[] hash; + try { + hash = sha256(HEX.parseHex(hexToken)); + } catch (IllegalArgumentException e) { + throw new NotAuthenticated("Malformed refresh token"); + } + + Optional found = refreshTokenRepository.findByHash(sha256(hash)); + if (found.isEmpty()) return; + + RefreshToken refreshToken = found.get(); + TokenFamily family = refreshToken.getTokenFamily(); + + if (family.getRevokedAt() == null) family.setRevokedAt(now); + if (refreshToken.getRevokedAt() == null) refreshToken.setRevokedAt(now); + } + public record Rotation(Long userId, String newRefreshToken) {} private static byte[] sha256(byte[] in) { @@ -153,5 +174,4 @@ public class TokenService { refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpiration.toMillis())); return refreshTokenRepository.save(refreshToken); } - } diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index 8fd4130..ff91495 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -85,6 +85,10 @@ public class UserService { return new TokenPackage(rotation.newRefreshToken(), jwt); } + public void logout(String refreshToken) { + tokenService.revoke(refreshToken); + } + /** * Looks up a user by their stream key. *