implement logout functions and refresh endpoint
All checks were successful
CI / Maven unit tests (push) Successful in 16s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

This commit is contained in:
2026-10-15 20:42:23 +01:00
parent 0bc6b3e8db
commit ed8e4881d6
3 changed files with 81 additions and 30 deletions

View File

@@ -3,16 +3,18 @@ package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.Token; import com.ddf.vodsystem.controllers.dto.Token;
import com.ddf.vodsystem.dto.TokenPackage; import com.ddf.vodsystem.dto.TokenPackage;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.UserService; import com.ddf.vodsystem.services.UserService;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletRequest; 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.ResponseCookie;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.Optional; import java.util.Optional;
@RestController @RestController
@@ -20,12 +22,18 @@ import java.util.Optional;
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
@Value("${app.security.expiration.jwt}") private final Duration jwtExpiration;
private long jwtExpiration; private final Duration refreshTokenExpiration;
private static final String SUCCESS = "success"; 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.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} * @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
*/ */
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<APIResponse<TokenPackage>> login(@RequestBody Token token, public ResponseEntity<APIResponse<Void>> login(@RequestBody Token token,
HttpServletRequest request, HttpServletRequest request,
HttpServletResponse response) { HttpServletResponse response) {
TokenPackage tokenPackage = userService.login(token.token()); TokenPackage tokenPackage = userService.login(token.token());
addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken());
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());
return ResponseEntity.ok( return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Logged in successfully", tokenPackage) new APIResponse<>(SUCCESS, "Logged in successfully", null)
);
}
@PostMapping("/refresh")
public ResponseEntity<APIResponse<Void>> 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. * 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 * @return {@code 200 OK} confirming logout
*/ */
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response, public ResponseEntity<APIResponse<Void>> logout(
HttpServletRequest request) { @CookieValue(name = "refreshToken", required = false) String refreshToken) {
ResponseCookie cookie = ResponseCookie.from("token", "") userService.logout(refreshToken);
.httpOnly(true)
.maxAge(0)
.sameSite(request.isSecure() ? "None" : "Lax")
.secure(request.isSecure())
.path("/")
.build();
response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok( return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Logged out successfully", null) 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());
}
} }

View File

@@ -17,6 +17,7 @@ import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional;
@Service @Service
public class TokenService { public class TokenService {
@@ -123,6 +124,26 @@ public class TokenService {
return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken)); 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<RefreshToken> 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) {} public record Rotation(Long userId, String newRefreshToken) {}
private static byte[] sha256(byte[] in) { private static byte[] sha256(byte[] in) {
@@ -153,5 +174,4 @@ public class TokenService {
refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpiration.toMillis())); refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpiration.toMillis()));
return refreshTokenRepository.save(refreshToken); return refreshTokenRepository.save(refreshToken);
} }
} }

View File

@@ -85,6 +85,10 @@ public class UserService {
return new TokenPackage(rotation.newRefreshToken(), jwt); return new TokenPackage(rotation.newRefreshToken(), jwt);
} }
public void logout(String refreshToken) {
tokenService.revoke(refreshToken);
}
/** /**
* Looks up a user by their stream key. * Looks up a user by their stream key.
* *