implement logout functions and refresh endpoint
This commit is contained in:
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user