13 Commits

Author SHA1 Message Date
19a5a49cbb add rotate tests
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-10 18:41:45 +01:00
4c7c2f0916 add rotate tests
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-09 17:44:30 +01:00
4b5e6ed8f6 add some createSession tests
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-08 19:51:44 +01:00
e2a2a60075 add clock dependency injection
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-07 19:03:47 +01:00
8166c62bb1 fix reused token within grace still throwing
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-07 18:45:27 +01:00
40fc80537b add lock to read by Hash
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-07 18:31:47 +01:00
11115a435a extract rotation logic to TokenService
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-05 19:30:50 +01:00
cbb8c2195f extract Google logic into separate adapter
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-04 14:54:23 +01:00
05361409b2 wip
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-03 21:08:32 +01:00
c533257e98 add refresh method & update login to use refresh tokens
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-03 02:05:24 +01:00
cc3765c284 add repos and services (wip)
Some checks failed
CI / Maven unit tests (push) Failing after 12s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-02 23:07:10 +01:00
0c04bf4c97 adjust schema slightly
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-01 22:17:24 +01:00
d7fb8ffb91 add refresh token tables & entities
All checks were successful
CI / Maven unit tests (push) Successful in 17s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-01 21:56:02 +01:00
21 changed files with 166 additions and 1238 deletions

View File

@@ -8,9 +8,9 @@ type AuthContextValue = {
loading: boolean; loading: boolean;
/** Exchange a Google credential for a session, then load the user. */ /** Exchange a Google credential for a session, then load the user. */
login: (googleCredential: string) => Promise<void>; login: (googleCredential: string) => Promise<void>;
/** End the session and clear the user. The local session is cleared even if the server call fails. */ /** End the session and clear the user. */
logout: () => Promise<void>; logout: () => Promise<void>;
/** Re-fetch the current user (e.g. after a profile change). Token refresh is handled automatically. */ /** Re-fetch the current user (e.g. after a profile change). */
refresh: () => Promise<void>; refresh: () => Promise<void>;
}; };

View File

@@ -1,66 +1,19 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { User } from "../utils/types.ts"; import type { User } from "../utils/types.ts";
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts"; import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
import { AuthContext } from "./AuthContext.ts"; import { AuthContext } from "./AuthContext.ts";
import { import {registerUnauthorizedHandler} from "../utils/api/client.ts";
refreshSession,
registerSessionRefreshedHandler,
registerUnauthorizedHandler,
} from "../utils/api/client.ts";
/** Refresh this long before the access token expires, so in-flight requests never see it lapse. */
const REFRESH_MARGIN_MS = 60_000;
/** How soon to try again when a scheduled refresh fails because the network is down. */
const RETRY_DELAY_MS = 30_000;
/** /**
* Owns the single source of truth for the authenticated user. Everything that * Owns the single source of truth for the authenticated user. Everything that
* needs to know who is logged in — the layout, the top bar, protected pages — * needs to know who is logged in — the layout, the top bar, protected pages —
* reads it through the `useAuth` hook rather than fetching the user itself. * reads it through the `useAuth` hook rather than fetching the user itself.
*
* It also keeps the session alive: the access token is refreshed shortly
* before it expires, rather than waiting for a request to fail. That matters
* for <video> elements, which load media straight from the API and can't
* retry on a 401 the way apiFetch does.
*/ */
const AuthProvider = ({ children }: { children: ReactNode }) => { const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** When the next refresh is due (epoch ms), or null when there's no session to keep alive. */
const refreshDueRef = useRef<number | null>(null);
const cancelScheduledRefresh = useCallback(() => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
refreshDueRef.current = null;
}, []);
const endSession = useCallback(() => {
cancelScheduledRefresh();
setUser(null);
}, [cancelScheduledRefresh]);
const scheduleRefreshIn = useCallback((delayMs: number) => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshDueRef.current = Date.now() + delayMs;
refreshTimerRef.current = setTimeout(() => {
refreshSession()
.then((refreshed) => { if (!refreshed) endSession(); })
.catch(() => scheduleRefreshIn(RETRY_DELAY_MS));
}, delayMs);
}, [endSession]);
// Every successful refresh — scheduled, or triggered by apiFetch on a 401 —
// reports the new access token lifetime here, so the timer always tracks
// the live cookie.
const scheduleRefreshBeforeExpiry = useCallback((accessTokenExpiresIn: number) => {
const lifetimeMs = accessTokenExpiresIn * 1000;
scheduleRefreshIn(Math.max(lifetimeMs - REFRESH_MARGIN_MS, lifetimeMs / 2));
}, [scheduleRefreshIn]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
setUser(await getUser()); setUser(await getUser());
@@ -71,50 +24,22 @@ const AuthProvider = ({ children }: { children: ReactNode }) => {
}, []); }, []);
useEffect(() => { useEffect(() => {
registerUnauthorizedHandler(endSession); refresh().finally(() => setLoading(false));
registerSessionRefreshedHandler(scheduleRefreshBeforeExpiry);
}, [endSession, scheduleRefreshBeforeExpiry]);
// Restore the session on page load. Refreshing first means a returning
// user whose access token expired while they were away is logged straight
// back in, and gives us the expiry to schedule the next refresh against.
useEffect(() => {
refreshSession()
.catch((error) => console.error("Failed to refresh session:", error))
.then(refresh)
.finally(() => setLoading(false));
}, [refresh]); }, [refresh]);
// Timers are throttled in background tabs and paused while the machine
// sleeps, so catch up as soon as the user comes back.
useEffect(() => {
const refreshIfDue = () => {
if (document.visibilityState !== "visible") return;
const due = refreshDueRef.current;
if (due !== null && Date.now() >= due) scheduleRefreshIn(0);
};
document.addEventListener("visibilitychange", refreshIfDue);
window.addEventListener("online", refreshIfDue);
return () => {
document.removeEventListener("visibilitychange", refreshIfDue);
window.removeEventListener("online", refreshIfDue);
};
}, [scheduleRefreshIn]);
useEffect(() => cancelScheduledRefresh, [cancelScheduledRefresh]);
const login = useCallback(async (googleCredential: string) => { const login = useCallback(async (googleCredential: string) => {
scheduleRefreshBeforeExpiry(await apiLogin(googleCredential)); await apiLogin(googleCredential);
await refresh(); await refresh();
}, [refresh, scheduleRefreshBeforeExpiry]); }, [refresh]);
const logout = useCallback(async () => { const logout = useCallback(async () => {
// Drop the local session even if the server call fails, so the user
// is never left looking logged in after clicking log out.
endSession();
await apiLogout(); await apiLogout();
}, [endSession]); await refresh();
}, [refresh]);
useEffect(() => {
registerUnauthorizedHandler(() => setUser(null));
}, []);
return ( return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}> <AuthContext.Provider value={{ user, loading, login, logout, refresh }}>

View File

@@ -1,25 +0,0 @@
import type { ReactNode } from "react";
import { groupByTimeRange } from "../../utils/utils";
type props<T> = {
items: T[];
getDate: (item: T) => string;
renderItem: (item: T) => ReactNode;
}
const TimeGroupedGrid = <T,>({ items, getDate, renderItem }: props<T>) => {
return (
<div className="flex flex-col gap-8">
{groupByTimeRange(items, getDate).map((group) => (
<section key={group.label}>
<h2 className="text-xl font-heading text-text-primary mb-3">{group.label}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map(renderItem)}
</div>
</section>
))}
</div>
);
}
export default TimeGroupedGrid;

View File

@@ -56,61 +56,50 @@
/* Dark mode. Only token values change here: every component already styles /* Dark mode. Only token values change here: every component already styles
itself with the semantic utilities above, so the whole UI follows from this itself with the semantic utilities above, so the whole UI follows from this
one block. `--color-on-dark` is deliberately absent — it is the ink on the one block. `--color-on-dark` is deliberately absent — it is the ink on the
media backdrop, which stays dark in both themes. media backdrop, which stays dark in both themes. */
Soft charcoal rather than pure black, with only a trace of warmth so it
still sits with the paper tones of the light theme. Ink is a warm off-white
instead of #FFF, and terracotta becomes a gentle clay. Layering matches the
light theme: sidebar recessed, cards raised, fields inset into the card. */
:root[data-theme="dark"] { :root[data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
/* ── Surfaces ────────────────────────────────────────────────────────── */ /* ── Surfaces ── near-black canvas, neutral cards lifting off it ─────── */
--color-background: #161615; --color-background: #0E0E10;
--color-sidebar: #111111; --color-sidebar: #09090B;
--color-card: #1F1F1E; --color-card: #17171A;
--color-fields: #1A1A19; --color-fields: #121215;
--color-dropzone: #1C1C1B; --color-dropzone: #141417;
--color-media-backdrop: #0B0B0B; --color-media-backdrop: #000000;
/* ── Ink ─────────────────────────────────────────────────────────────── */ /* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #F5F4EF; --color-text-primary: #F4F4F5;
--color-text-strong: #E8E6E0; --color-text-strong: #E3E3E7;
--color-text-body: #C9C7C0; --color-text-body: #BFBFC6;
--color-text-secondary: #A2A099; --color-text-secondary: #94949D;
--color-muted: #87857F; --color-muted: #76767F;
--color-faint-mono: #6C6B66; --color-faint-mono: #6A6A73;
/* ── Accents ── clay and sage; hover lightens rather than deepens ────── */ /* ── Accents ── brightened to carry on a dark canvas, so hover now lifts
--color-terracotta: #D97757; rather than deepens ─────────────────────────────────────────────────── */
--color-terracotta-hover: #E28B6E; --color-terracotta: #E07A4C;
--color-terracotta-light: #BC6A4E; --color-terracotta-hover: #F08F62;
--color-terracotta-disabled: #46372F; --color-terracotta-light: #F0A177;
--color-olive: #A7AF84; --color-terracotta-disabled: #5E463A;
--color-olive: #A3B678;
/* ── Status ──────────────────────────────────────────────────────────── */ /* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #E0644E; --color-live: #F2604A;
--color-error: #E0644E; --color-error: #F2604A;
/* ── Ink on accent surfaces ── the accents are light here, so the ink that /* ── Ink on accent surfaces ── the accents are light here, so the ink that
sits on them flips dark ─────────────────────────────────────────────── */ sits on them flips dark ─────────────────────────────────────────────── */
--color-on-accent: #1A1410; --color-on-accent: #17100B;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */ /* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #282827; --color-hairline: #232327;
--color-hover: #2A2A29; --color-hover: #26262B;
--color-selected: #2A2522; --color-selected: #2A1F18;
--color-ring: #3B3B3A; --color-ring: #33333A;
--color-inactive: #333332; --color-inactive: #2C2C32;
--color-inactive-strong: #5A5955; --color-inactive-strong: #55555E;
}
/* Tailwind's shadows default to 10% black, which vanishes on a dark canvas.
`--tw-shadow-color` does not inherit, so it is set on the shadowed elements
themselves; a deeper warm-black keeps cards and menus visibly lifted. */
:root[data-theme="dark"] :where(.shadow-sm, .shadow-md) {
--tw-shadow-color: rgb(0 0 0 / 0.45);
} }
:root { :root {

View File

@@ -1,22 +1,18 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/api/clips"; import { getClips, deleteClip } from "../utils/api/clips";
import { stringToDate } from "../utils/utils";
import type { Clip } from "../utils/types"; import type { Clip } from "../utils/types";
const MyClips = () => { const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]); const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
getClips() getClips()
.then((data) => setClips(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime()))) .then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err)) .catch((err) => setError(err));
.finally(() => setLoading(false));
}, []); }, []);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
@@ -32,10 +28,8 @@ const MyClips = () => {
<div className="p-6"> <div className="p-6">
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1> <h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
<TimeGroupedGrid <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
items={clips} {clips.map((clip) => (
getDate={(clip) => clip.createdAt}
renderItem={(clip) => (
<VideoCard <VideoCard
key={clip.id} key={clip.id}
id={clip.id} id={clip.id}
@@ -45,10 +39,10 @@ const MyClips = () => {
onEdit={() => navigate(`/clips/${clip.id}/edit`)} onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)} onDelete={() => handleDelete(clip.id)}
/> />
)} ))}
/> </div>
{clips.length === 0 && !error && !loading && ( {clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-muted"> <div className="flex flex-col items-center justify-center py-16 text-muted">
<p className="text-base">No clips yet.</p> <p className="text-base">No clips yet.</p>
<p className="text-sm mt-1">Upload your first clip to get started.</p> <p className="text-sm mt-1">Upload your first clip to get started.</p>

View File

@@ -1,22 +1,18 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getVods, deleteVod } from "../utils/api/vods"; import { getVods, deleteVod } from "../utils/api/vods";
import { stringToDate } from "../utils/utils";
import type { Vod } from "../utils/types"; import type { Vod } from "../utils/types";
const MyVods = () => { const MyVods = () => {
const [vods, setVods] = useState<Vod[]>([]); const [vods, setVods] = useState<Vod[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
getVods() getVods()
.then((data) => setVods(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime()))) .then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods")) .catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
.finally(() => setLoading(false));
}, []); }, []);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
@@ -32,10 +28,8 @@ const MyVods = () => {
<div className="p-6"> <div className="p-6">
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1> <h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
<TimeGroupedGrid <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
items={vods} {vods.map((vod) => (
getDate={(vod) => vod.createdAt}
renderItem={(vod) => (
<VideoCard <VideoCard
key={vod.id} key={vod.id}
id={vod.id} id={vod.id}
@@ -48,10 +42,10 @@ const MyVods = () => {
onEdit={() => navigate(`/vods/${vod.id}/edit`)} onEdit={() => navigate(`/vods/${vod.id}/edit`)}
onDelete={() => handleDelete(vod.id)} onDelete={() => handleDelete(vod.id)}
/> />
)} ))}
/> </div>
{vods.length === 0 && !error && !loading && ( {vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-muted"> <div className="flex flex-col items-center justify-center py-16 text-muted">
<p className="text-base">No VoDs yet.</p> <p className="text-base">No VoDs yet.</p>
<p className="text-sm mt-1">Your stream recordings will appear here.</p> <p className="text-sm mt-1">Your stream recordings will appear here.</p>

View File

@@ -1,5 +1,4 @@
import {config} from "../../config.ts"; import {config} from "../../config.ts";
import type { APIResponse } from "../types.ts";
const API_URL = config.apiUrl; const API_URL = config.apiUrl;
@@ -8,72 +7,28 @@ class AuthError extends Error {
} }
let onUnauthorized: (() => void) | null = null; let onUnauthorized: (() => void) | null = null;
let onSessionRefreshed: ((accessTokenExpiresIn: number) => void) | null = null;
let refreshInFlight: Promise<boolean> | null = null;
export function registerUnauthorizedHandler(handler: () => void) { export function registerUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler; onUnauthorized = handler;
} }
/** Called with the new access token lifetime (in seconds) whenever the session is refreshed. */
export function registerSessionRefreshedHandler(handler: (accessTokenExpiresIn: number) => void) {
onSessionRefreshed = handler;
}
/** /**
* Exchanges the refresh token cookie for new access and refresh token cookies. * Performs a request against the API, sending session cookies and funnelling
* Concurrent callers share a single request, so a burst of 401s only rotates * expired/missing sessions through the registered unauthorized handler so the
* the refresh token once. * UI can drop back to a logged out state. Returns the raw response so callers
* * that need something other than JSON (blobs, streams) can read it themselves.
* @returns true if the session was refreshed, false if it has ended (no valid
* refresh token). Throws on network errors so a dropped connection isn't
* mistaken for being logged out.
*/
function refreshSession(): Promise<boolean> {
refreshInFlight ??= requestRefresh().finally(() => { refreshInFlight = null; });
return refreshInFlight;
}
async function requestRefresh(): Promise<boolean> {
const response = await fetch(API_URL + '/api/v1/users/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) return false;
const result: APIResponse = await response.json();
onSessionRefreshed?.(result.data.accessTokenExpiresIn);
return true;
}
/**
* Performs a request against the API, sending session cookies. An expired
* access token is refreshed and the request retried once, so callers only see
* an AuthError when the session has really ended — at which point the
* registered unauthorized handler drops the UI back to a logged out state.
* Returns the raw response so callers that need something other than JSON
* (blobs, streams) can read it themselves.
*/ */
async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> { async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> {
const send = () => fetch(url, { ...options, credentials: 'include' }); const response = await fetch(url, {
...options,
credentials: 'include'
});
let response = await send(); if (response.status == 401 || response.status == 403) {
if (response.status == 401 && await refreshSession()) {
response = await send();
}
if (response.status == 401) {
onUnauthorized?.(); onUnauthorized?.();
throw new AuthError(); throw new AuthError();
} }
// Logged in, but not allowed to access this resource: don't end the session.
if (response.status == 403) {
throw new AuthError();
}
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
let message = text; let message = text;
@@ -90,4 +45,4 @@ async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
return text ? JSON.parse(text) : (undefined as T); return text ? JSON.parse(text) : (undefined as T);
} }
export { API_URL, AuthError, apiFetch, apiFetchRaw, refreshSession }; export { API_URL, AuthError, apiFetch, apiFetchRaw };

View File

@@ -1,17 +1,10 @@
import type { APIResponse, User } from "../types.ts"; import type { APIResponse, User } from "../types.ts";
import { API_URL } from "./client.ts"; import { API_URL, apiFetch } from "./client.ts";
// These deliberately use fetch directly rather than apiFetch: they run when // login and getUser deliberately use fetch directly: they run when there may be
// there may be no session at all, and apiFetch's refresh-and-retry on 401 // no session at all, and getUser is what the unauthorized handler calls to
// would either loop or log the user out from under the auth provider. // refresh — routing them through apiFetch would loop on a 401.
const login = async (googleToken: string): Promise<string> => {
/**
* Exchanges a Google credential for a session. The tokens are set as HttpOnly
* cookies; only the access token lifetime is returned.
*
* @returns seconds until the access token expires
*/
const login = async (googleToken: string): Promise<number> => {
const response = await fetch(API_URL + '/api/v1/users/login', { const response = await fetch(API_URL + '/api/v1/users/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -24,17 +17,12 @@ const login = async (googleToken: string): Promise<number> => {
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`); if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
return result.data.accessTokenExpiresIn; return result.data.token;
}; };
/** Revokes the session and clears the auth cookies. */
const logout = async (): Promise<void> => { const logout = async (): Promise<void> => {
const response = await fetch(API_URL + '/api/v1/users/logout', { const result = await apiFetch<APIResponse>(API_URL + '/api/v1/users/logout', { method: 'POST' });
method: 'POST', if (result?.status === 'error') throw new Error(`Logout failed: ${result.message}`);
credentials: 'include',
});
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
}; };
const getUser = async (): Promise<User | null> => { const getUser = async (): Promise<User | null> => {

View File

@@ -46,52 +46,4 @@ function dateToTimeAgo(date: Date): string {
return `${s} second${s !== 1 ? 's' : ''} ago`; return `${s} second${s !== 1 ? 's' : ''} ago`;
} }
function startOfDay(date: Date): Date { export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime };
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
// Weeks start on Monday.
function startOfWeek(date: Date): Date {
const day = startOfDay(date);
day.setDate(day.getDate() - ((day.getDay() + 6) % 7));
return day;
}
// Buckets a date relative to now. Anything before last month falls back to its
// month and year, so older items still group sensibly.
function timeRangeLabel(date: Date, now: Date = new Date()): string {
const today = startOfDay(now);
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const thisWeek = startOfWeek(now);
const lastWeek = new Date(thisWeek);
lastWeek.setDate(thisWeek.getDate() - 7);
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
if (date >= today) return "Today";
if (date >= yesterday) return "Yesterday";
if (date >= thisWeek) return "This Week";
if (date >= lastWeek) return "Last Week";
if (date >= thisMonth) return "This Month";
if (date >= lastMonth) return "Last Month";
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
type TimeGroup<T> = { label: string, items: T[] };
// Expects items already sorted newest first; groups keep that order.
function groupByTimeRange<T>(items: T[], getDateString: (item: T) => string): TimeGroup<T>[] {
const now = new Date();
const groups: TimeGroup<T>[] = [];
for (const item of items) {
const label = timeRangeLabel(stringToDate(getDateString(item)), now);
const last = groups[groups.length - 1];
if (last?.label === label) last.items.push(item);
else groups.push({ label, items: [item] });
}
return groups;
}
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime, timeRangeLabel, groupByTimeRange };
export type { TimeGroup };

View File

@@ -1,12 +1,10 @@
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;
@@ -26,7 +24,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, {@code 401} for unauthenticated requests, and per-endpoint authorization rules. * installed, 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}
@@ -43,14 +41,11 @@ 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").authenticated() .requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
// authenticated by the refresh token cookie, not the (possibly expired) access token .requestMatchers("/api/v1/users/login").permitAll()
.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)
); );

View File

@@ -1,21 +1,18 @@
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.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.http.HttpHeaders; import org.springframework.beans.factory.annotation.Value;
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
@@ -23,18 +20,12 @@ import java.util.Optional;
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
private final Duration jwtExpiration; @Value("${app.security.expiration.jwt}")
private final Duration refreshTokenExpiration; private long jwtExpiration;
private static final String SUCCESS = "success"; private static final String SUCCESS = "success";
private static final String ACCESS_COOKIE_PATH = "/"; public UserController(UserService userService) {
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();
} }
/** /**
@@ -57,110 +48,59 @@ public class UserController {
} }
/** /**
* Authenticates a user from a Google ID token and starts a session. * Authenticates a user from a Google ID token and issues a session JWT.
* <p> * <p>
* Sets two HttpOnly cookies: {@code accessToken} (the JWT, path {@code /}) and {@code refreshToken} * The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also
* (path {@code /api/v1/users}). Neither token is returned in the response body. * 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 the cookies' {@code Secure} flag * @param request the incoming request, used to decide cookie {@code Secure}/{@code SameSite} flags
* @param response the response the cookies are written to * @param response the response the session cookie is written to
* @return {@code 200 OK} wrapping the access token lifetime as a {@link SessionResponse} * @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
* @throws NotAuthenticated if the Google ID token is rejected
*/ */
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<APIResponse<SessionResponse>> login(@RequestBody Token token, public ResponseEntity<APIResponse<TokenPackage>> 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", sessionResponse()) new APIResponse<>(SUCCESS, "Logged in successfully", tokenPackage)
); );
} }
/** /**
* Exchanges the {@code refreshToken} cookie for a new refresh token and JWT, replacing both cookies. * Logs the user out by clearing the session cookie.
* *
* @param refreshToken the refresh token from the {@code refreshToken} cookie, if present * @param response the response the expired cookie is written to
* @param request the incoming request, used to decide the cookies' {@code Secure} flag * @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags
* @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( public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
@CookieValue(name = "refreshToken", required = false) String refreshToken, HttpServletRequest request) {
HttpServletRequest request, ResponseCookie cookie = ResponseCookie.from("token", "")
HttpServletResponse response) { .httpOnly(true)
userService.logout(refreshToken); .maxAge(0)
writeCookies(request, response, "", Duration.ZERO, "", Duration.ZERO); .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 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());
}
} }

View File

@@ -1,11 +0,0 @@
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
) {}

View File

@@ -11,7 +11,7 @@ public class ClipOptions {
private String description; private String description;
@NotNull @NotNull
@PositiveOrZero @Positive
private Float startPoint; private Float startPoint;
@NotNull @Positive @NotNull @Positive

View File

@@ -2,29 +2,9 @@ package com.ddf.vodsystem.repositories;
import com.ddf.vodsystem.entities.TokenFamily; import com.ddf.vodsystem.entities.TokenFamily;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.Instant;
@Repository @Repository
public interface TokenFamilyRepository extends JpaRepository<TokenFamily, Long> { 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);
} }

View File

@@ -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 -> "accessToken".equals(cookie.getName())) .filter(cookie -> "token".equals(cookie.getName()))
.map(Cookie::getValue) .map(Cookie::getValue)
.findFirst() .findFirst()
.orElse(null); .orElse(null);

View File

@@ -4,9 +4,12 @@ import java.util.Date;
import com.auth0.jwt.JWT; import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.exceptions.JWTDecodeException;
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 com.ddf.vodsystem.dto.properties.AuthProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@@ -57,7 +60,7 @@ public class JwtService {
} }
return jwt.getClaim(USER_ID_CLAIM).asLong(); return jwt.getClaim(USER_ID_CLAIM).asLong();
} catch (JWTVerificationException | IllegalArgumentException ignored) { } catch (JwtException | IllegalArgumentException | TokenExpiredException | JWTDecodeException ignored) {
return null; return null;
} }
} }

View File

@@ -7,9 +7,6 @@ import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.RefreshTokenRepository; import com.ddf.vodsystem.repositories.RefreshTokenRepository;
import com.ddf.vodsystem.repositories.TokenFamilyRepository; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -20,18 +17,9 @@ 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;
/**
* 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 @Service
public class TokenService { public class TokenService {
private static final Logger logger = LoggerFactory.getLogger(TokenService.class);
private static final SecureRandom RNG = new SecureRandom(); private static final SecureRandom RNG = new SecureRandom();
private static final HexFormat HEX = HexFormat.of(); private static final HexFormat HEX = HexFormat.of();
@@ -44,12 +32,6 @@ public class TokenService {
private final Duration refreshTokenExpiration; private final Duration refreshTokenExpiration;
private final Duration refreshGracePeriod; 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 ( public TokenService (
TokenFamilyRepository tokenFamilyRepository, TokenFamilyRepository tokenFamilyRepository,
RefreshTokenRepository refreshTokenRepository, RefreshTokenRepository refreshTokenRepository,
@@ -65,12 +47,6 @@ public class TokenService {
this.refreshGracePeriod = props.expiration().gracePeriod(); 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 @Transactional
public String createSession(User user) { public String createSession(User user) {
Instant now = Instant.now(clock); Instant now = Instant.now(clock);
@@ -91,19 +67,6 @@ public class TokenService {
return HEX.formatHex(token); 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) @Transactional(noRollbackFor = NotAuthenticated.class)
public Rotation rotate(String hexToken) { public Rotation rotate(String hexToken) {
if (hexToken == null || hexToken.isBlank()) { if (hexToken == null || hexToken.isBlank()) {
@@ -160,61 +123,6 @@ public class TokenService {
return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken)); 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) {} public record Rotation(Long userId, String newRefreshToken) {}
private static byte[] sha256(byte[] in) { private static byte[] sha256(byte[] in) {
@@ -245,4 +153,5 @@ 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

@@ -8,8 +8,6 @@ import com.ddf.vodsystem.repositories.UserRepository;
import com.ddf.vodsystem.security.GoogleVerifierService; import com.ddf.vodsystem.security.GoogleVerifierService;
import com.ddf.vodsystem.security.JwtService; import com.ddf.vodsystem.security.JwtService;
import com.ddf.vodsystem.security.TokenService; import com.ddf.vodsystem.security.TokenService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
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;
@@ -22,8 +20,6 @@ import java.util.Optional;
@Service @Service
public class UserService { public class UserService {
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
private final GoogleVerifierService googleVerifierService; private final GoogleVerifierService googleVerifierService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final JwtService jwtService; private final JwtService jwtService;
@@ -65,14 +61,11 @@ public class UserService {
} }
/** /**
* Verifies a Google ID token, creates or updates the matching user, and starts a new session. * Verifies a Google ID token, creating or updating the corresponding user, and issues a session JWT.
* <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 new refresh token and a signed JWT access token for the user * @return a signed JWT for the authenticated user
* @throws NotAuthenticated if the ID token is invalid, has no subject, or its email is unverified * @throws NotAuthenticated if the token is invalid or its subject is missing
*/ */
@Transactional @Transactional
public TokenPackage login(String idToken) { public TokenPackage login(String idToken) {
@@ -82,43 +75,16 @@ public class UserService {
String refreshToken = tokenService.createSession(user); String refreshToken = tokenService.createSession(user);
String jwt = jwtService.generateToken(user.getId()); String jwt = jwtService.generateToken(user.getId());
logger.debug("Access and Refresh tokens issued for User ID {}", user.getId());
return new TokenPackage(refreshToken, jwt); return new TokenPackage(refreshToken, jwt);
} }
/**
* 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) { public TokenPackage refresh(String refreshToken) {
TokenService.Rotation rotation = tokenService.rotate(refreshToken); TokenService.Rotation rotation = tokenService.rotate(refreshToken);
String jwt = jwtService.generateToken(rotation.userId()); String jwt = jwtService.generateToken(rotation.userId());
logger.debug("New refresh and access token issued for user ID {}", rotation.userId());
return new TokenPackage(rotation.newRefreshToken(), jwt); 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()");
}
}
/** /**
* Looks up a user by their stream key. * Looks up a user by their stream key.
* *

View File

@@ -21,11 +21,15 @@ 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
app.security.jwt-secret=${JWT_SECRET_KEY} app.security.jwt-secret=${JWT_SECRET_KEY}
# 1 Hour
app.security.expiration.jwt=3600000
# 7 days
app.security.expiration.refresh-token=604800000
# 90 days
app.security.expiration.token-family=7776000000
app.security.expiration.jwt=5m # 5 seconds
app.security.expiration.refresh-token=7d app.security.expiration.grace-period=5000
app.security.expiration.token-family=90d
app.security.expiration.grace-period=5s
# Frontend # Frontend
frontend.url=${FRONTEND_URL} frontend.url=${FRONTEND_URL}

View File

@@ -17,7 +17,6 @@ import org.mockito.Captor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import java.security.MessageDigest;
import java.time.*; import java.time.*;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional; import java.util.Optional;
@@ -75,7 +74,7 @@ class TokenServiceTest {
Duration.ofSeconds(10), Duration.ofSeconds(10),
Duration.ofSeconds(10), Duration.ofSeconds(10),
Duration.ofSeconds(10), Duration.ofSeconds(10),
Duration.ofSeconds(5) Duration.ofSeconds(10)
) )
); );
@@ -187,9 +186,12 @@ class TokenServiceTest {
@Test @Test
void revokedFamily_throwsNotAuthenticated() { void revokedFamily_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily(); TokenFamily tokenFamily = new TokenFamily();
tokenFamily.setId(1L);
tokenFamily.setRevokedAt(Instant.now(clock)); tokenFamily.setRevokedAt(Instant.now(clock));
RefreshToken refreshToken = tokenIn(tokenFamily);
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily);
clock.advance(Duration.ofMinutes(1)); clock.advance(Duration.ofMinutes(1));
@@ -206,8 +208,14 @@ class TokenServiceTest {
// Happy Path // Happy Path
@Test @Test
void unusedToken_returnsUserIdAndRefreshToken() { void unusedToken_returnsUserIdAndRefreshToken() {
TokenFamily tokenFamily = activeFamily(); TokenFamily tokenFamily = new TokenFamily();
RefreshToken refreshToken = tokenIn(tokenFamily); tokenFamily.setId(1L);
tokenFamily.setUser(createUser(1L, "user", "user"));
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily);
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
when(refreshTokenRepository.findByHash(any())) when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken)); .thenReturn(Optional.of(refreshToken));
@@ -220,8 +228,14 @@ class TokenServiceTest {
@Test @Test
void unsedToken_savesRefreshToken() { void unsedToken_savesRefreshToken() {
TokenFamily tokenFamily = activeFamily(); TokenFamily tokenFamily = new TokenFamily();
RefreshToken refreshToken = tokenIn(tokenFamily); tokenFamily.setId(1L);
tokenFamily.setUser(createUser(1L, "user", "user"));
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily);
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
when(refreshTokenRepository.findByHash(any())) when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken)); .thenReturn(Optional.of(refreshToken));
@@ -243,8 +257,14 @@ class TokenServiceTest {
@Test @Test
void ununsedToken_savesOldTokenWithUsedAt() { void ununsedToken_savesOldTokenWithUsedAt() {
TokenFamily tokenFamily = activeFamily(); TokenFamily tokenFamily = new TokenFamily();
RefreshToken refreshToken = tokenIn(tokenFamily); tokenFamily.setId(1L);
tokenFamily.setUser(createUser(1L, "user", "user"));
tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10));
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily);
refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10));
when(refreshTokenRepository.findByHash(any())) when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken)); .thenReturn(Optional.of(refreshToken));
@@ -261,368 +281,6 @@ class TokenServiceTest {
assertEquals(savedToken.getUsedAt(), clock.instant()); assertEquals(savedToken.getUsedAt(), clock.instant());
} }
@Test
void unusedToken_doesNotRevokeFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
tokenService.rotate(rawToken);
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
RefreshToken newToken = tokenCaptor.getAllValues().stream()
.filter(t -> t.getUsedAt() == null)
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
.orElseThrow(() -> new AssertionError("no unused token was saved"));
assertNull(newToken.getTokenFamily().getRevokedAt());
}
@Test
void unusedToken_newTokenIsDifferentFromOldToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setTokenHash(rawToken.getBytes());
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertNotEquals(rawToken, rotation.newRefreshToken());
}
// Grace period checks
@Test
void usedWithinGrace_returnsNewToken() {
Instant usedAt = Instant.now(clock);
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setTokenHash(rawToken.getBytes());
refreshToken.setUsedAt(usedAt);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
// within grace: 3s since usedAt, grace is 5s, and nothing has expired yet
clock.advance(Duration.ofSeconds(3));
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertEquals(1L, rotation.userId());
assertNotEquals(rawToken, rotation.newRefreshToken());
assertNull(tokenFamily.getRevokedAt());
verify(tokenFamilyRepository, never()).save(any());
// grace is not extended by a replay, and only the new token is written
assertEquals(usedAt, refreshToken.getUsedAt());
verify(refreshTokenRepository, times(1)).save(tokenCaptor.capture());
RefreshToken newToken = tokenCaptor.getValue();
assertNull(newToken.getUsedAt());
assertSame(tokenFamily, newToken.getTokenFamily());
assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash());
}
@Test
void usedAtExactGraceBoundary_returnsNewToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(authProperties.expiration().gracePeriod());
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertEquals(1L, rotation.userId());
assertNull(tokenFamily.getRevokedAt());
}
// Reuse after grace period
@Test
void usedAfterGrace_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(6));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
}
@Test
void usedAfterGrace_revokesFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(6));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
verify(tokenFamilyRepository).save(familyCaptor.capture());
assertSame(tokenFamily, familyCaptor.getValue());
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
}
@Test
void usedAfterGrace_doesNotIssueNewToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(6));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
verify(refreshTokenRepository, never()).save(any());
}
@Test
void usedAfterGrace_subsequentRotateRejectedWithoutResaving() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(6));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
verify(tokenFamilyRepository, times(1)).save(any());
}
// Expiry
@Test
void expiredToken_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setExpiresAt(clock.instant().minusSeconds(1));
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
}
@Test
void expiredFamily_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily();
tokenFamily.setExpiresAt(clock.instant().minusSeconds(1));
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
}
@Test
void expiredToken_doesNotSaveOrRevoke() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(11));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
verify(refreshTokenRepository, never()).save(any());
verify(tokenFamilyRepository, never()).save(any());
assertNull(refreshToken.getUsedAt());
assertNull(tokenFamily.getRevokedAt());
}
@Test
void tokenAtExactExpiry_returnsNewToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(10));
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertEquals(1L, rotation.userId());
}
// Ordering
@Test
void revokedFamilyAndUsedAfterGrace_doesNotRevokeAgain() {
TokenFamily tokenFamily = activeFamily();
Instant revokedAt = clock.instant();
tokenFamily.setRevokedAt(revokedAt);
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(6));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
assertEquals(revokedAt, tokenFamily.getRevokedAt());
verify(tokenFamilyRepository, never()).save(any());
}
@Test
void revokedAndExpiredFamily_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily();
tokenFamily.setRevokedAt(clock.instant());
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(11));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
}
@Test
void expiredAndUsedAfterGrace_revokesFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(11));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
verify(tokenFamilyRepository).save(tokenFamily);
}
@Test
void expiredWithinGrace_doesNotRevokeFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setExpiresAt(clock.instant().plusSeconds(2));
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(3));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
assertNull(tokenFamily.getRevokedAt());
verify(tokenFamilyRepository, never()).save(any());
verify(refreshTokenRepository, never()).save(any());
}
@Test
void unknownToken_doesNotTouchRepositoriesFurther() {
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
verify(refreshTokenRepository, never()).save(any());
verifyNoInteractions(tokenFamilyRepository);
}
} }
@Nested
@DisplayName("revoke()")
class revoke {
@Test
void knownToken_revokesTokenAndFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
tokenService.revoke(rawToken);
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
assertEquals(clock.instant(), refreshToken.getRevokedAt());
}
@Test
void knownToken_savesTokenAndFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
tokenService.revoke(rawToken);
verify(tokenFamilyRepository).save(tokenFamily);
verify(refreshTokenRepository).save(refreshToken);
}
@Test
void knownToken_looksUpBySingleSha256Hash() throws Exception {
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
tokenService.revoke(rawToken);
byte[] expectedHash = MessageDigest.getInstance("SHA-256").digest(HEX.parseHex(rawToken));
verify(refreshTokenRepository).findByHash(expectedHash);
}
@Test
void alreadyRevoked_keepsOriginalTimestampsAndDoesNotSave() {
Instant revokedAt = clock.instant();
TokenFamily tokenFamily = activeFamily();
tokenFamily.setRevokedAt(revokedAt);
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setRevokedAt(revokedAt);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofMinutes(1));
tokenService.revoke(rawToken);
assertEquals(revokedAt, tokenFamily.getRevokedAt());
assertEquals(revokedAt, refreshToken.getRevokedAt());
verify(tokenFamilyRepository, never()).save(any());
verify(refreshTokenRepository, never()).save(any());
}
@Test
void unknownToken_doesNothing() {
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
tokenService.revoke(rawToken);
verify(refreshTokenRepository, never()).save(any());
verifyNoInteractions(tokenFamilyRepository);
}
@Test
void nullToken_doesNothing() {
tokenService.revoke(null);
verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository);
}
@Test
void blankToken_doesNothing() {
tokenService.revoke("");
verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository);
}
@Test
void malformedToken_throwsNotAuthenticated() {
assertThrows(
NotAuthenticated.class,
() -> tokenService.revoke("not-hex")
);
verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository);
}
}
private TokenFamily activeFamily() {
TokenFamily tokenFamily = new TokenFamily();
tokenFamily.setId(1L);
tokenFamily.setUser(createUser(1L, "user", "user"));
tokenFamily.setExpiresAt(clock.instant().plusSeconds(10));
return tokenFamily;
}
private RefreshToken tokenIn(TokenFamily tokenFamily) {
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily);
refreshToken.setExpiresAt(clock.instant().plusSeconds(10));
return refreshToken;
}
} }

View File

@@ -1,295 +1,7 @@
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.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.UserRepository;
import com.ddf.vodsystem.security.GoogleVerifierService;
import com.ddf.vodsystem.security.JwtService;
import com.ddf.vodsystem.security.TokenService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest { class UserServiceTest {
@Mock private GoogleVerifierService googleVerifierService;
@Mock private JwtService jwtService;
@Mock private UserRepository userRepository;
@Mock private TokenService tokenService;
@Captor ArgumentCaptor<User> userCaptor;
private static final String ID_TOKEN = "google-id-token";
private static final String REFRESH_TOKEN = "deadbeefdeadbeefdeadbeefdeadbeef";
private static final String NEW_REFRESH_TOKEN = "cafebabecafebabecafebabecafebabe";
private static final String JWT = "header.payload.signature";
private static final String GOOGLE_ID = "google-id";
UserService userService;
User createUser(Long id, String username, String email) {
User user = new User();
user.setId(id);
user.setUsername(username);
user.setEmail(email);
return user;
}
GoogleUser googleUser() {
return new GoogleUser(
GOOGLE_ID,
"user@example.com",
"My User",
"https://lol.com/1"
);
}
@BeforeEach
void setUp() {
userService = new UserService(
userRepository,
jwtService,
googleVerifierService,
tokenService
);
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Nested
@DisplayName("getUserById()")
class getUserById {
@Test
void existingId_returnsUser() {
User user = createUser(1L, "user", "user@example.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
Optional<User> result = userService.getUserById(1L);
assertTrue(result.isPresent());
assertSame(user, result.get());
}
@Test
void unknownId_returnsEmpty() {
when(userRepository.findById(1L)).thenReturn(Optional.empty());
assertTrue(userService.getUserById(1L).isEmpty());
}
}
@Nested
@DisplayName("getLoggedInUser()")
class getLoggedInUser {
@Test
void authenticatedUserPrincipal_returnsUser() {
User user = createUser(1L, "user", "user@example.com");
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user, null, List.of())
);
Optional<User> result = userService.getLoggedInUser();
assertTrue(result.isPresent());
assertSame(user, result.get());
}
@Test
void noAuthentication_returnsEmpty() {
assertTrue(userService.getLoggedInUser().isEmpty());
}
@Test
void unauthenticated_returnsEmpty() {
User user = createUser(1L, "user", "user@example.com");
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user, null)
);
assertTrue(userService.getLoggedInUser().isEmpty());
}
@Test
void nonUserPrincipal_returnsEmpty() {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("anonymous", null, List.of())
);
assertTrue(userService.getLoggedInUser().isEmpty());
}
}
@Nested
@DisplayName("login()")
class login {
@Test
void newUser_savesUserToRepo() {
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
userService.login(ID_TOKEN);
verify(userRepository).save(userCaptor.capture());
User saved = userCaptor.getValue();
assertEquals(GOOGLE_ID, saved.getGoogleId());
assertEquals("user@example.com", saved.getEmail());
assertEquals("user@example.com", saved.getUsername());
assertEquals("My User", saved.getName());
assertEquals("https://lol.com/1", saved.getProfilePictureUrl());
assertEquals(0, saved.getRole());
assertNotNull(saved.getCreatedAt());
}
@Test
void newUser_generatesHexStreamKeyOf48Characters() {
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
userService.login(ID_TOKEN);
verify(userRepository).save(userCaptor.capture());
String streamKey = userCaptor.getValue().getStreamKey();
assertEquals(48, streamKey.length());
assertTrue(streamKey.matches("[0-9a-f]+"), () -> "not hex: " + streamKey);
}
@Test
void existingUser_updatesUserInfo() {
User existingUser = createUser(1L, "old-username", "old@example.com");
existingUser.setGoogleId(GOOGLE_ID);
existingUser.setName("Old Name");
existingUser.setProfilePictureUrl("https://lol.com/old");
existingUser.setStreamKey("123");
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.of(existingUser));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
userService.login(ID_TOKEN);
verify(userRepository).save(userCaptor.capture());
User saved = userCaptor.getValue();
assertSame(existingUser, saved);
assertEquals(1L, saved.getId());
assertEquals(GOOGLE_ID, saved.getGoogleId());
assertEquals("user@example.com", saved.getEmail());
assertEquals("My User", saved.getName());
assertEquals("https://lol.com/1", saved.getProfilePictureUrl());
assertEquals("old-username", saved.getUsername());
assertEquals("123", saved.getStreamKey());
}
@Test
void validToken_returnsRefreshTokenAndJwt() {
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(tokenService.createSession(any())).thenReturn(REFRESH_TOKEN);
when(jwtService.generateToken(any())).thenReturn(JWT);
TokenPackage tokenPackage = userService.login(ID_TOKEN);
assertEquals(REFRESH_TOKEN, tokenPackage.refreshToken());
assertEquals(JWT, tokenPackage.accessToken());
}
@Test
void validToken_createsSessionForSavedUser() {
User savedUser = createUser(42L, "user@example.com", "user@example.com");
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
when(userRepository.save(any())).thenReturn(savedUser);
userService.login(ID_TOKEN);
verify(tokenService).createSession(savedUser);
verify(jwtService).generateToken(42L);
}
@Test
void invalidToken_throwsNotAuthenticated() {
when(googleVerifierService.verify(ID_TOKEN))
.thenThrow(new NotAuthenticated("Invalid ID token"));
assertThrows(
NotAuthenticated.class,
() -> userService.login(ID_TOKEN)
);
verifyNoInteractions(userRepository, tokenService, jwtService);
}
}
@Nested
@DisplayName("refresh()")
class refresh {
@Test
void validToken_returnsRotatedTokenAndJwt() {
when(tokenService.rotate(REFRESH_TOKEN))
.thenReturn(new TokenService.Rotation(1L, NEW_REFRESH_TOKEN));
when(jwtService.generateToken(1L)).thenReturn(JWT);
TokenPackage tokenPackage = userService.refresh(REFRESH_TOKEN);
assertEquals(NEW_REFRESH_TOKEN, tokenPackage.refreshToken());
assertEquals(JWT, tokenPackage.accessToken());
}
@Test
void invalidToken_throwsNotAuthenticated() {
when(tokenService.rotate(REFRESH_TOKEN))
.thenThrow(new NotAuthenticated("Invalid refresh token"));
assertThrows(
NotAuthenticated.class,
() -> userService.refresh(REFRESH_TOKEN)
);
verifyNoInteractions(jwtService);
}
}
@Nested
@DisplayName("logout()")
class logout {
@Test
void revokesRefreshToken() {
userService.logout(REFRESH_TOKEN);
verify(tokenService).revoke(REFRESH_TOKEN);
}
@Test
void doesNotIssueTokens() {
userService.logout(REFRESH_TOKEN);
verify(tokenService, never()).createSession(any());
verify(tokenService, never()).rotate(any());
verifyNoInteractions(jwtService);
}
}
} }