feat(security): add refresh token capability to API (#52)
## Add backend refresh tokens
Adds rotating server-side refresh tokens so the access token can be short-lived (5m) without forcing frequent re-login.
- New `POST /api/v1/users/refresh` exchanges the `refreshToken` cookie for a new refresh token and JWT
- Each login starts a token family; reusing a used token after the 5s grace period revokes the whole family
- Logout revokes the family; an hourly job deletes dead families
- Google ID token verification extracted into `GoogleVerifierService`; config moved to a validated `AuthProperties`
- Unauthenticated requests now return 401 instead of 403
- Migration `V2__refresh_tokens.sql` adds `token_families` and `refresh_tokens`
**Breaking changes**
- Cookie `token` renamed to `accessToken` (existing sessions are logged out)
- `/login` and `/refresh` no longer return the JWT in the body, only `{ accessTokenExpiresIn }`
- `jwt.secret.key` / `jwt.expiration` replaced by `app.security.*`; `JWT_SECRET_KEY` must be 32+ chars
- Frontend must call `/refresh` on 401 or before the access token expires
Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
package com.ddf.vodsystem;
|
package com.ddf.vodsystem;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
|
@EnableConfigurationProperties(AuthProperties.class)
|
||||||
public class VodSystemApplication {
|
public class VodSystemApplication {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.ddf.vodsystem.configuration;
|
package com.ddf.vodsystem.configuration;
|
||||||
|
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.Customizer;
|
import org.springframework.security.config.Customizer;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
@@ -24,7 +26,7 @@ public class SecurityConfig {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
|
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
|
||||||
* installed, and per-endpoint authorization rules.
|
* installed, {@code 401} for unauthenticated requests, and per-endpoint authorization rules.
|
||||||
*
|
*
|
||||||
* @param http the HTTP security builder to configure
|
* @param http the HTTP security builder to configure
|
||||||
* @return the built {@link SecurityFilterChain}
|
* @return the built {@link SecurityFilterChain}
|
||||||
@@ -41,11 +43,14 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/v1/media/compress").permitAll() // compression available without authenticated
|
.requestMatchers("/api/v1/media/compress").permitAll() // compression available without authenticated
|
||||||
.requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs
|
.requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs
|
||||||
.requestMatchers("/api/v1/media/**").authenticated()
|
.requestMatchers("/api/v1/media/**").authenticated()
|
||||||
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
|
.requestMatchers("/api/v1/users/me").authenticated()
|
||||||
.requestMatchers("/api/v1/users/login").permitAll()
|
// authenticated by the refresh token cookie, not the (possibly expired) access token
|
||||||
|
.requestMatchers("/api/v1/users/login", "/api/v1/users/refresh", "/api/v1/users/logout").permitAll()
|
||||||
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
|
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
|
// 401 rather than the default 403, so clients know to refresh their session
|
||||||
|
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.ddf.vodsystem.configuration;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class TimeConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Clock clock() {
|
||||||
|
return Clock.systemUTC();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
package com.ddf.vodsystem.controllers;
|
package com.ddf.vodsystem.controllers;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.APIResponse;
|
import com.ddf.vodsystem.dto.APIResponse;
|
||||||
|
import com.ddf.vodsystem.controllers.dto.SessionResponse;
|
||||||
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.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
|
||||||
@@ -19,12 +23,18 @@ import java.util.Optional;
|
|||||||
public class UserController {
|
public class UserController {
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
|
||||||
@Value("${jwt.expiration}")
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,59 +57,110 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticates a user from a Google ID token and issues a session JWT.
|
* Authenticates a user from a Google ID token and starts a session.
|
||||||
* <p>
|
* <p>
|
||||||
* The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also
|
* Sets two HttpOnly cookies: {@code accessToken} (the JWT, path {@code /}) and {@code refreshToken}
|
||||||
* returned in the response body.
|
* (path {@code /api/v1/users}). Neither token is returned in the response body.
|
||||||
*
|
*
|
||||||
* @param token the payload carrying the Google ID token to verify
|
* @param token the payload carrying the Google ID token to verify
|
||||||
* @param request the incoming request, used to decide cookie {@code Secure}/{@code SameSite} flags
|
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
|
||||||
* @param response the response the session cookie is written to
|
* @param response the response the cookies are written to
|
||||||
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
|
* @return {@code 200 OK} wrapping the access token lifetime as a {@link SessionResponse}
|
||||||
|
* @throws NotAuthenticated if the Google ID token is rejected
|
||||||
*/
|
*/
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
|
public ResponseEntity<APIResponse<SessionResponse>> login(@RequestBody Token token,
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
HttpServletResponse response) {
|
HttpServletResponse response) {
|
||||||
String jwt = userService.login(token.token());
|
TokenPackage tokenPackage = userService.login(token.token());
|
||||||
|
addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken());
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", jwt)
|
|
||||||
.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", new Token(jwt))
|
new APIResponse<>(SUCCESS, "Logged in successfully", sessionResponse())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs the user out by clearing the session cookie.
|
* Exchanges the {@code refreshToken} cookie for a new refresh token and JWT, replacing both cookies.
|
||||||
*
|
*
|
||||||
* @param response the response the expired cookie is written to
|
* @param refreshToken the refresh token from the {@code refreshToken} cookie, if present
|
||||||
* @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags
|
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
|
||||||
|
* @param response the response the new cookies are written to
|
||||||
|
* @return {@code 200 OK} wrapping the new access token lifetime as a {@link SessionResponse}
|
||||||
|
* @throws NotAuthenticated if the refresh token is missing or rejected
|
||||||
|
*/
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public ResponseEntity<APIResponse<SessionResponse>> refresh(
|
||||||
|
@CookieValue(name = "refreshToken", required = false) String refreshToken,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
TokenPackage tokenPackage = userService.refresh(refreshToken);
|
||||||
|
addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
new APIResponse<>(SUCCESS, "Refreshed tokens successfully", sessionResponse())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the user out by revoking the session tied to the {@code refreshToken} cookie and clearing
|
||||||
|
* both auth cookies. Succeeds even if no refresh token cookie is present.
|
||||||
|
*
|
||||||
|
* @param refreshToken the refresh token from the {@code refreshToken} cookie, if present
|
||||||
|
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
|
||||||
|
* @param response the response the expired cookies are written to
|
||||||
* @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", "")
|
HttpServletRequest request,
|
||||||
.httpOnly(true)
|
HttpServletResponse response) {
|
||||||
.maxAge(0)
|
userService.logout(refreshToken);
|
||||||
.sameSite(request.isSecure() ? "None" : "Lax")
|
writeCookies(request, response, "", Duration.ZERO, "", Duration.ZERO);
|
||||||
.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 SessionResponse sessionResponse() {
|
||||||
|
return new SessionResponse(jwtExpiration.getSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addCookies(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
String accessToken,
|
||||||
|
String refreshToken) {
|
||||||
|
writeCookies(request, response, accessToken, jwtExpiration, refreshToken, refreshTokenExpiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void writeCookies(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
String accessToken,
|
||||||
|
Duration accessTokenMaxAge,
|
||||||
|
String refreshToken,
|
||||||
|
Duration refreshTokenMaxAge) {
|
||||||
|
ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", accessToken)
|
||||||
|
.httpOnly(true)
|
||||||
|
.maxAge(accessTokenMaxAge)
|
||||||
|
.sameSite("Lax")
|
||||||
|
.secure(request.isSecure())
|
||||||
|
.path(ACCESS_COOKIE_PATH)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken)
|
||||||
|
.httpOnly(true)
|
||||||
|
.maxAge(refreshTokenMaxAge)
|
||||||
|
.sameSite("Lax")
|
||||||
|
.secure(request.isSecure())
|
||||||
|
.path(REFRESH_COOKIE_PATH)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session metadata returned after login or refresh. The tokens themselves are only ever sent as
|
||||||
|
* HttpOnly cookies; this tells the client when to refresh them.
|
||||||
|
*
|
||||||
|
* @param accessTokenExpiresIn seconds until the access token cookie expires
|
||||||
|
*/
|
||||||
|
public record SessionResponse(
|
||||||
|
long accessTokenExpiresIn
|
||||||
|
) {}
|
||||||
9
src/main/java/com/ddf/vodsystem/dto/GoogleUser.java
Normal file
9
src/main/java/com/ddf/vodsystem/dto/GoogleUser.java
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package com.ddf.vodsystem.dto;
|
||||||
|
|
||||||
|
public record GoogleUser(
|
||||||
|
String googleId,
|
||||||
|
String email,
|
||||||
|
String name,
|
||||||
|
String profilePictureUrl
|
||||||
|
) {
|
||||||
|
}
|
||||||
7
src/main/java/com/ddf/vodsystem/dto/TokenPackage.java
Normal file
7
src/main/java/com/ddf/vodsystem/dto/TokenPackage.java
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package com.ddf.vodsystem.dto;
|
||||||
|
|
||||||
|
public record TokenPackage (
|
||||||
|
String refreshToken,
|
||||||
|
String accessToken
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.ddf.vodsystem.dto.properties;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@Validated
|
||||||
|
@ConfigurationProperties(prefix = "app.security")
|
||||||
|
public record AuthProperties (
|
||||||
|
@NotBlank @Size(min = 32) String jwtSecret,
|
||||||
|
@Valid @NotNull Expiration expiration
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param jwt
|
||||||
|
* @param refreshToken
|
||||||
|
* @param tokenFamily
|
||||||
|
* @param gracePeriod
|
||||||
|
*/
|
||||||
|
public record Expiration (
|
||||||
|
@NotNull Duration jwt,
|
||||||
|
@NotNull Duration refreshToken,
|
||||||
|
@NotNull Duration tokenFamily,
|
||||||
|
@NotNull Duration gracePeriod
|
||||||
|
) {}
|
||||||
|
}
|
||||||
37
src/main/java/com/ddf/vodsystem/entities/RefreshToken.java
Normal file
37
src/main/java/com/ddf/vodsystem/entities/RefreshToken.java
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package com.ddf.vodsystem.entities;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "refresh_tokens")
|
||||||
|
@Data
|
||||||
|
public class RefreshToken {
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", nullable = false, unique = true)
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "family_id", nullable = false)
|
||||||
|
private TokenFamily tokenFamily;
|
||||||
|
|
||||||
|
@Column(name = "token_hash", unique = true, nullable = false, length = 32)
|
||||||
|
private byte[] tokenHash;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
@CreationTimestamp
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "used_at")
|
||||||
|
private Instant usedAt;
|
||||||
|
|
||||||
|
@Column(name = "revoked_at")
|
||||||
|
private Instant revokedAt;
|
||||||
|
}
|
||||||
31
src/main/java/com/ddf/vodsystem/entities/TokenFamily.java
Normal file
31
src/main/java/com/ddf/vodsystem/entities/TokenFamily.java
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package com.ddf.vodsystem.entities;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "token_families")
|
||||||
|
@Data
|
||||||
|
public class TokenFamily {
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", nullable = false, unique = true)
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
@CreationTimestamp
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "revoked_at")
|
||||||
|
private Instant revokedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ddf.vodsystem.repositories;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.entities.RefreshToken;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("SELECT r FROM RefreshToken r WHERE r.tokenHash = ?1")
|
||||||
|
Optional<RefreshToken> findByHash(byte[] tokenHash);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.ddf.vodsystem.repositories;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.entities.TokenFamily;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TokenFamilyRepository extends JpaRepository<TokenFamily, Long> {
|
||||||
|
/**
|
||||||
|
* Deletes families that can no longer be refreshed: revoked, expired, or with every refresh
|
||||||
|
* token expired. Their refresh tokens are removed by the {@code ON DELETE CASCADE} foreign key.
|
||||||
|
*
|
||||||
|
* @return the number of families deleted
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("""
|
||||||
|
DELETE FROM TokenFamily f
|
||||||
|
WHERE f.revokedAt IS NOT NULL
|
||||||
|
OR f.expiresAt <= ?1
|
||||||
|
OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM RefreshToken r
|
||||||
|
WHERE r.tokenFamily = f AND r.expiresAt > ?1
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
int deleteDead(Instant now);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.ddf.vodsystem.security;
|
||||||
|
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||||
|
import com.google.api.client.http.javanet.NetHttpTransport;
|
||||||
|
import com.google.api.client.json.gson.GsonFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class GoogleAuthConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the verifier used to validate Google ID tokens issued for this application's client ID.
|
||||||
|
*
|
||||||
|
* @param googleClientId the OAuth client ID that tokens must be issued for
|
||||||
|
* @return the configured {@link GoogleIdTokenVerifier}
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public GoogleIdTokenVerifier googleIdTokenVerifier(
|
||||||
|
@Value("${spring.security.oauth2.client.registration.google.client-id}") String googleClientId) {
|
||||||
|
return new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory())
|
||||||
|
.setAudience(Collections.singletonList(googleClientId))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ddf.vodsystem.security;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.GoogleUser;
|
||||||
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class GoogleVerifierService {
|
||||||
|
private final GoogleIdTokenVerifier verifier;
|
||||||
|
|
||||||
|
public GoogleVerifierService(GoogleIdTokenVerifier verifier) {
|
||||||
|
this.verifier = verifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GoogleUser verify(String idToken) {
|
||||||
|
GoogleIdToken token;
|
||||||
|
try {
|
||||||
|
token = verifier.verify(idToken);
|
||||||
|
} catch (GeneralSecurityException | IOException e) {
|
||||||
|
throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token == null) {
|
||||||
|
throw new NotAuthenticated("Invalid ID token");
|
||||||
|
}
|
||||||
|
|
||||||
|
String googleId = token.getPayload().getSubject();
|
||||||
|
|
||||||
|
if (googleId == null) {
|
||||||
|
throw new NotAuthenticated("Google ID does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Boolean.TRUE.equals(token.getPayload().getEmailVerified())) {
|
||||||
|
throw new NotAuthenticated("Email not verified");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GoogleUser(
|
||||||
|
googleId,
|
||||||
|
token.getPayload().getEmail(),
|
||||||
|
(String) token.getPayload().get("name"),
|
||||||
|
(String) token.getPayload().get("picture")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ public class JwtFilter extends OncePerRequestFilter {
|
|||||||
Cookie[] cookies = request.getCookies();
|
Cookie[] cookies = request.getCookies();
|
||||||
if (cookies != null) {
|
if (cookies != null) {
|
||||||
jwt = Arrays.stream(cookies)
|
jwt = Arrays.stream(cookies)
|
||||||
.filter(cookie -> "token".equals(cookie.getName()))
|
.filter(cookie -> "accessToken".equals(cookie.getName()))
|
||||||
.map(Cookie::getValue)
|
.map(Cookie::getValue)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.auth0.jwt.algorithms.Algorithm;
|
|||||||
import com.auth0.jwt.exceptions.JWTDecodeException;
|
import com.auth0.jwt.exceptions.JWTDecodeException;
|
||||||
import com.auth0.jwt.exceptions.TokenExpiredException;
|
import com.auth0.jwt.exceptions.TokenExpiredException;
|
||||||
import com.auth0.jwt.interfaces.DecodedJWT;
|
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||||
|
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.oauth2.jwt.JwtException;
|
import org.springframework.security.oauth2.jwt.JwtException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -20,10 +21,9 @@ public class JwtService {
|
|||||||
private static final String USER_ID_CLAIM = "userId";
|
private static final String USER_ID_CLAIM = "userId";
|
||||||
private static final String ISSUER = "vodsystem";
|
private static final String ISSUER = "vodsystem";
|
||||||
|
|
||||||
public JwtService(@Value("${jwt.secret.key}") String jwtSecretKey,
|
public JwtService(AuthProperties props) {
|
||||||
@Value("${jwt.expiration}") long jwtExpiration) {
|
this.jwtExpiration = props.expiration().jwt().toMillis();
|
||||||
this.jwtExpiration = jwtExpiration;
|
this.algorithm = Algorithm.HMAC256(props.jwtSecret());
|
||||||
this.algorithm = Algorithm.HMAC256(jwtSecretKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
248
src/main/java/com/ddf/vodsystem/security/TokenService.java
Normal file
248
src/main/java/com/ddf/vodsystem/security/TokenService.java
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package com.ddf.vodsystem.security;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
||||||
|
import com.ddf.vodsystem.entities.RefreshToken;
|
||||||
|
import com.ddf.vodsystem.entities.TokenFamily;
|
||||||
|
import com.ddf.vodsystem.entities.User;
|
||||||
|
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||||
|
import com.ddf.vodsystem.repositories.RefreshTokenRepository;
|
||||||
|
import com.ddf.vodsystem.repositories.TokenFamilyRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues, rotates and revokes opaque refresh tokens.
|
||||||
|
* <p>
|
||||||
|
* Each login starts a {@link TokenFamily}; every refresh token issued for that session belongs to it.
|
||||||
|
* Only the SHA-256 hash of a token is stored. Presenting an already-used token after the grace
|
||||||
|
* period is treated as theft and revokes the whole family.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class TokenService {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TokenService.class);
|
||||||
|
private static final SecureRandom RNG = new SecureRandom();
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
private final TokenFamilyRepository tokenFamilyRepository;
|
||||||
|
private final RefreshTokenRepository refreshTokenRepository;
|
||||||
|
|
||||||
|
private final Duration tokenFamilyExpiration;
|
||||||
|
private final Duration refreshTokenExpiration;
|
||||||
|
private final Duration refreshGracePeriod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param tokenFamilyRepository stores token families (one per login session)
|
||||||
|
* @param refreshTokenRepository stores hashed refresh tokens
|
||||||
|
* @param props supplies token family lifetime, refresh token lifetime and reuse grace period
|
||||||
|
* @param clock time source for all timestamps and expiry checks
|
||||||
|
*/
|
||||||
|
public TokenService (
|
||||||
|
TokenFamilyRepository tokenFamilyRepository,
|
||||||
|
RefreshTokenRepository refreshTokenRepository,
|
||||||
|
AuthProperties props,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.tokenFamilyRepository = tokenFamilyRepository;
|
||||||
|
this.refreshTokenRepository = refreshTokenRepository;
|
||||||
|
this.clock = clock;
|
||||||
|
|
||||||
|
this.refreshTokenExpiration = props.expiration().refreshToken();
|
||||||
|
this.tokenFamilyExpiration = props.expiration().tokenFamily();
|
||||||
|
this.refreshGracePeriod = props.expiration().gracePeriod();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a new session for a user by creating a token family and its first refresh token.
|
||||||
|
*
|
||||||
|
* @param user the user the session belongs to
|
||||||
|
* @return the raw refresh token as a 64-character hex string; only its hash is persisted
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String createSession(User user) {
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
|
||||||
|
TokenFamily tokenFamily = new TokenFamily();
|
||||||
|
tokenFamily.setUser(user);
|
||||||
|
tokenFamily.setCreatedAt(now);
|
||||||
|
tokenFamily.setExpiresAt(now.plusMillis(tokenFamilyExpiration.toMillis()));
|
||||||
|
tokenFamilyRepository.save(tokenFamily);
|
||||||
|
|
||||||
|
byte[] token = generateRandomBytes(32);
|
||||||
|
|
||||||
|
createRefreshToken(
|
||||||
|
token,
|
||||||
|
tokenFamily
|
||||||
|
);
|
||||||
|
|
||||||
|
return HEX.formatHex(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges a refresh token for a new one in the same family.
|
||||||
|
* <p>
|
||||||
|
* The presented token is marked used and a new token is issued. Presenting a used token again
|
||||||
|
* within the grace period returns another new token without extending the grace period. Presenting
|
||||||
|
* it after the grace period revokes the family; this revocation is committed even though the
|
||||||
|
* method throws.
|
||||||
|
*
|
||||||
|
* @param hexToken the raw refresh token as a hex string
|
||||||
|
* @return the owning user's ID and the new raw refresh token
|
||||||
|
* @throws NotAuthenticated if the token is missing, malformed, unknown, expired, belongs to a revoked
|
||||||
|
* or expired family, or is reused after the grace period
|
||||||
|
*/
|
||||||
|
@Transactional(noRollbackFor = NotAuthenticated.class)
|
||||||
|
public Rotation rotate(String hexToken) {
|
||||||
|
if (hexToken == null || hexToken.isBlank()) {
|
||||||
|
throw new NotAuthenticated("Missing refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] hash;
|
||||||
|
try {
|
||||||
|
hash = sha256(HEX.parseHex(hexToken));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new NotAuthenticated("Malformed refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
|
||||||
|
// Check Token is real
|
||||||
|
RefreshToken refreshToken = refreshTokenRepository.findByHash(hash)
|
||||||
|
.orElseThrow(() -> new NotAuthenticated("No such refresh token"));
|
||||||
|
|
||||||
|
// Find Token Family
|
||||||
|
TokenFamily tokenFamily = refreshToken.getTokenFamily();
|
||||||
|
|
||||||
|
if (tokenFamily.getRevokedAt() != null) {
|
||||||
|
throw new NotAuthenticated("Token family has been revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse Check
|
||||||
|
if (refreshToken.getUsedAt() != null && now.isAfter(refreshToken.getUsedAt().plus(refreshGracePeriod))) {
|
||||||
|
tokenFamily.setRevokedAt(now);
|
||||||
|
tokenFamilyRepository.save(tokenFamily);
|
||||||
|
throw new NotAuthenticated("Refresh token already being used");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry Check
|
||||||
|
if (
|
||||||
|
now.isAfter(refreshToken.getExpiresAt()) ||
|
||||||
|
now.isAfter(tokenFamily.getExpiresAt())
|
||||||
|
) {
|
||||||
|
throw new NotAuthenticated("Refresh token or family expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate old token, unless we're within the grace period, otherwise the grace gets extended
|
||||||
|
if (refreshToken.getUsedAt() == null) {
|
||||||
|
refreshToken.setUsedAt(now);
|
||||||
|
refreshTokenRepository.save(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] newRawToken = generateRandomBytes(32);
|
||||||
|
createRefreshToken(
|
||||||
|
newRawToken,
|
||||||
|
tokenFamily
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes a refresh token and its whole token family, ending the session. Does nothing if the
|
||||||
|
* token is missing or unknown, and leaves existing revocation timestamps unchanged.
|
||||||
|
*
|
||||||
|
* @param hexToken the raw refresh token as a hex string, may be {@code null}
|
||||||
|
* @throws NotAuthenticated if the token is not valid hex
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void revoke(String hexToken) {
|
||||||
|
if (hexToken == null || hexToken.isBlank()) return;
|
||||||
|
|
||||||
|
byte[] hash;
|
||||||
|
try {
|
||||||
|
hash = sha256(HEX.parseHex(hexToken));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new NotAuthenticated("Malformed refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<RefreshToken> found = refreshTokenRepository.findByHash(hash);
|
||||||
|
if (found.isEmpty()) return;
|
||||||
|
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
RefreshToken refreshToken = found.get();
|
||||||
|
TokenFamily family = refreshToken.getTokenFamily();
|
||||||
|
|
||||||
|
if (family.getRevokedAt() == null) {
|
||||||
|
family.setRevokedAt(now);
|
||||||
|
tokenFamilyRepository.save(family);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshToken.getRevokedAt() == null) {
|
||||||
|
refreshToken.setRevokedAt(now);
|
||||||
|
refreshTokenRepository.save(refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled cleanup that deletes token families that can no longer be refreshed, along with all
|
||||||
|
* their refresh tokens.
|
||||||
|
* <p>
|
||||||
|
* Runs hourly. Used tokens in live families are kept, since reuse detection needs them.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelay = 3_600_000)
|
||||||
|
@Transactional
|
||||||
|
public void deleteDeadSessions() {
|
||||||
|
int deleted = tokenFamilyRepository.deleteDead(Instant.now(clock));
|
||||||
|
if (deleted > 0) logger.info("Deleted {} dead token families", deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a successful {@link #rotate(String)}.
|
||||||
|
*
|
||||||
|
* @param userId ID of the user who owns the token family
|
||||||
|
* @param newRefreshToken the newly issued raw refresh token as a hex string
|
||||||
|
*/
|
||||||
|
public record Rotation(Long userId, String newRefreshToken) {}
|
||||||
|
|
||||||
|
private static byte[] sha256(byte[] in) {
|
||||||
|
try {
|
||||||
|
return MessageDigest.getInstance("SHA-256").digest(in);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] generateRandomBytes(int length) {
|
||||||
|
byte[] bytes = new byte[length];
|
||||||
|
RNG.nextBytes(bytes);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private RefreshToken createRefreshToken(
|
||||||
|
byte[] token,
|
||||||
|
TokenFamily tokenFamily
|
||||||
|
) {
|
||||||
|
Instant now = Instant.now(clock);
|
||||||
|
|
||||||
|
RefreshToken refreshToken = new RefreshToken();
|
||||||
|
refreshToken.setTokenFamily(tokenFamily);
|
||||||
|
refreshToken.setTokenHash(sha256(token));
|
||||||
|
refreshToken.setCreatedAt(now);
|
||||||
|
refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpiration.toMillis()));
|
||||||
|
return refreshTokenRepository.save(refreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +1,44 @@
|
|||||||
package com.ddf.vodsystem.services;
|
package com.ddf.vodsystem.services;
|
||||||
|
|
||||||
|
import com.ddf.vodsystem.dto.GoogleUser;
|
||||||
|
import com.ddf.vodsystem.dto.TokenPackage;
|
||||||
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.repositories.UserRepository;
|
import com.ddf.vodsystem.repositories.UserRepository;
|
||||||
|
import com.ddf.vodsystem.security.GoogleVerifierService;
|
||||||
import com.ddf.vodsystem.security.JwtService;
|
import com.ddf.vodsystem.security.JwtService;
|
||||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
import com.ddf.vodsystem.security.TokenService;
|
||||||
import com.google.api.client.http.javanet.NetHttpTransport;
|
import org.slf4j.Logger;
|
||||||
import com.google.api.client.json.JsonFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import com.google.api.client.json.gson.GsonFactory;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.security.GeneralSecurityException;
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class UserService {
|
public class UserService {
|
||||||
private final GoogleIdTokenVerifier verifier;
|
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
|
||||||
|
|
||||||
|
private final GoogleVerifierService googleVerifierService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
|
|
||||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||||
|
private final TokenService tokenService;
|
||||||
|
|
||||||
public UserService(UserRepository userRepository,
|
public UserService(UserRepository userRepository,
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
@Value("${google.client.id}") String googleClientId) {
|
GoogleVerifierService googleVerifierService,
|
||||||
|
TokenService tokenService) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
|
this.googleVerifierService = googleVerifierService;
|
||||||
NetHttpTransport transport = new NetHttpTransport();
|
|
||||||
JsonFactory jsonFactory = new GsonFactory();
|
|
||||||
|
|
||||||
this.verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory)
|
|
||||||
.setAudience(Collections.singletonList(googleClientId))
|
|
||||||
.build();
|
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
|
this.tokenService = tokenService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,24 +65,58 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies a Google ID token, creating or updating the corresponding user, and issues a session JWT.
|
* Verifies a Google ID token, creates or updates the matching user, and starts a new session.
|
||||||
|
* <p>
|
||||||
|
* A new user gets a username equal to their email, the default role and a random stream key.
|
||||||
|
* Email, name and profile picture are refreshed from Google on every login.
|
||||||
*
|
*
|
||||||
* @param idToken the Google ID token to verify
|
* @param idToken the Google ID token to verify
|
||||||
* @return a signed JWT for the authenticated user
|
* @return a new refresh token and a signed JWT access token for the user
|
||||||
* @throws NotAuthenticated if the token is invalid or its subject is missing
|
* @throws NotAuthenticated if the ID token is invalid, has no subject, or its email is unverified
|
||||||
*/
|
*/
|
||||||
public String login(String idToken) {
|
@Transactional
|
||||||
GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
|
public TokenPackage login(String idToken) {
|
||||||
String googleId = googleIdToken.getPayload().getSubject();
|
GoogleUser googleUser = googleVerifierService.verify(idToken);
|
||||||
|
User user = createOrUpdateGoogleUser(googleUser);
|
||||||
|
|
||||||
if (googleId == null) {
|
String refreshToken = tokenService.createSession(user);
|
||||||
throw new NotAuthenticated("Invalid ID token");
|
String jwt = jwtService.generateToken(user.getId());
|
||||||
|
|
||||||
|
logger.debug("Access and Refresh tokens issued for User ID {}", user.getId());
|
||||||
|
|
||||||
|
return new TokenPackage(refreshToken, jwt);
|
||||||
}
|
}
|
||||||
|
|
||||||
User googleUser = getGoogleUser(googleIdToken);
|
/**
|
||||||
User user = createOrUpdateUser(googleUser);
|
* Rotates a refresh token and issues a new JWT access token for its owner.
|
||||||
|
*
|
||||||
|
* @param refreshToken the raw refresh token as a hex string
|
||||||
|
* @return the new refresh token and a signed JWT access token
|
||||||
|
* @throws NotAuthenticated if the refresh token is rejected; see {@link TokenService#rotate(String)}
|
||||||
|
*/
|
||||||
|
public TokenPackage refresh(String refreshToken) {
|
||||||
|
TokenService.Rotation rotation = tokenService.rotate(refreshToken);
|
||||||
|
String jwt = jwtService.generateToken(rotation.userId());
|
||||||
|
|
||||||
return jwtService.generateToken(user.getId());
|
logger.debug("New refresh and access token issued for user ID {}", rotation.userId());
|
||||||
|
|
||||||
|
return new TokenPackage(rotation.newRefreshToken(), jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the session tied to a refresh token by revoking its token family.
|
||||||
|
* Already-issued JWT access tokens stay valid until they expire.
|
||||||
|
*
|
||||||
|
* Does nothing if the token is missing, malformed or unknown.
|
||||||
|
*
|
||||||
|
* @param refreshToken the raw refresh token as a hex string, may be {@code null}
|
||||||
|
*/
|
||||||
|
public void logout(String refreshToken) {
|
||||||
|
try {
|
||||||
|
tokenService.revoke(refreshToken);
|
||||||
|
} catch (NotAuthenticated e) {
|
||||||
|
logger.info("Malformed or missing token on logout()");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,51 +129,30 @@ public class UserService {
|
|||||||
return userRepository.findByStreamKey(streamKey);
|
return userRepository.findByStreamKey(streamKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private User createOrUpdateUser(User user) {
|
private User createOrUpdateGoogleUser(GoogleUser googleUser) {
|
||||||
Optional<User> existingUser = userRepository.findByGoogleId(user.getGoogleId());
|
Optional<User> existingUser = userRepository.findByGoogleId(googleUser.googleId());
|
||||||
|
User user;
|
||||||
|
|
||||||
if (existingUser.isEmpty()) {
|
if (existingUser.isEmpty()) {
|
||||||
|
user = new User();
|
||||||
|
user.setGoogleId(googleUser.googleId());
|
||||||
|
user.setUsername(googleUser.email());
|
||||||
user.setRole(0);
|
user.setRole(0);
|
||||||
user.setCreatedAt(Instant.now());
|
user.setCreatedAt(Instant.now());
|
||||||
user.setStreamKey(generateStreamKey());
|
user.setStreamKey(HexFormat.of().formatHex(generateRandomBytes(24)));
|
||||||
return userRepository.saveAndFlush(user);
|
} else {
|
||||||
|
user = existingUser.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
User existing = existingUser.get();
|
user.setEmail(googleUser.email());
|
||||||
existing.setEmail(user.getEmail());
|
user.setName(googleUser.name());
|
||||||
existing.setName(user.getName());
|
user.setProfilePictureUrl(googleUser.profilePictureUrl());
|
||||||
existing.setProfilePictureUrl(user.getProfilePictureUrl());
|
return userRepository.save(user);
|
||||||
existing.setUsername(user.getUsername());
|
|
||||||
return userRepository.saveAndFlush(existing);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private User getGoogleUser(GoogleIdToken idToken) {
|
private byte[] generateRandomBytes(int length) {
|
||||||
String googleId = idToken.getPayload().getSubject();
|
byte[] bytes = new byte[length];
|
||||||
String email = idToken.getPayload().getEmail();
|
|
||||||
String name = (String) idToken.getPayload().get("name");
|
|
||||||
String profilePictureUrl = (String) idToken.getPayload().get("picture");
|
|
||||||
|
|
||||||
User user = new User();
|
|
||||||
user.setGoogleId(googleId);
|
|
||||||
user.setEmail(email);
|
|
||||||
user.setName(name);
|
|
||||||
user.setUsername(email);
|
|
||||||
user.setProfilePictureUrl(profilePictureUrl);
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private GoogleIdToken getGoogleIdToken(String idToken) {
|
|
||||||
try {
|
|
||||||
return verifier.verify(idToken);
|
|
||||||
} catch (GeneralSecurityException | IOException e) {
|
|
||||||
throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String generateStreamKey() {
|
|
||||||
byte[] bytes = new byte[24];
|
|
||||||
SECURE_RANDOM.nextBytes(bytes);
|
SECURE_RANDOM.nextBytes(bytes);
|
||||||
return HexFormat.of().formatHex(bytes);
|
return bytes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ spring.flyway.locations=classpath:db/migrations
|
|||||||
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
|
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
|
||||||
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
|
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
|
||||||
spring.security.oauth2.client.registration.google.scope=profile,email
|
spring.security.oauth2.client.registration.google.scope=profile,email
|
||||||
jwt.secret.key=${JWT_SECRET_KEY}
|
app.security.jwt-secret=${JWT_SECRET_KEY}
|
||||||
jwt.expiration=3600000
|
|
||||||
|
app.security.expiration.jwt=5m
|
||||||
|
app.security.expiration.refresh-token=7d
|
||||||
|
app.security.expiration.token-family=90d
|
||||||
|
app.security.expiration.grace-period=5s
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
frontend.url=${FRONTEND_URL}
|
frontend.url=${FRONTEND_URL}
|
||||||
|
|||||||
27
src/main/resources/db/migrations/V2__refresh_tokens.sql
Normal file
27
src/main/resources/db/migrations/V2__refresh_tokens.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS token_families
|
||||||
|
(
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_families_user_id
|
||||||
|
ON token_families (user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens
|
||||||
|
(
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
family_id BIGINT NOT NULL REFERENCES token_families (id) ON DELETE CASCADE,
|
||||||
|
token_hash BYTEA NOT NULL UNIQUE, -- unique automatically creates a B-tree index
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
CONSTRAINT refresh_tokens_hash_len CHECK (octet_length(token_hash) = 32)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family_id
|
||||||
|
ON refresh_tokens (family_id);
|
||||||
Reference in New Issue
Block a user