1 Commits

Author SHA1 Message Date
13869c577b 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-09-20 20:37:56 +01:00
37 changed files with 230 additions and 2000 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 };

10
pom.xml
View File

@@ -90,16 +90,6 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- Database Migration Tools -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -1,16 +1,13 @@
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 {
/** /**

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,15 +0,0 @@
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();
}
}

View File

@@ -1,21 +1,19 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.StreamKey;
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.entities.Stream;
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 +21,12 @@ import java.util.Optional;
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
private final Duration jwtExpiration; @Value("${jwt.expiration}")
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 +49,67 @@ 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<Token>> login(@RequestBody Token token,
HttpServletRequest request, HttpServletRequest request,
HttpServletResponse response) { HttpServletResponse response) {
TokenPackage tokenPackage = userService.login(token.token()); String jwt = 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", sessionResponse()) new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt))
); );
} }
/** /**
* 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() { @PostMapping("/me/stream-key")
return new SessionResponse(jwtExpiration.getSeconds()); public ResponseEntity<APIResponse<StreamKey>> regenerateStreamKey() {
} String streamKey = userService.regenerateStreamKey();
return ResponseEntity.ok(
private void addCookies( new APIResponse<>(SUCCESS, "Stream key successfully", new StreamKey(streamKey))
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

@@ -0,0 +1,4 @@
package com.ddf.vodsystem.controllers.dto;
public record StreamKey (String streamKey) {
}

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

@@ -1,9 +0,0 @@
package com.ddf.vodsystem.dto;
public record GoogleUser(
String googleId,
String email,
String name,
String profilePictureUrl
) {
}

View File

@@ -1,7 +0,0 @@
package com.ddf.vodsystem.dto;
public record TokenPackage (
String refreshToken,
String accessToken
) {
}

View File

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

View File

@@ -1,37 +0,0 @@
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;
}

View File

@@ -1,31 +0,0 @@
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;
}

View File

@@ -1,17 +0,0 @@
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);
}

View File

@@ -1,30 +0,0 @@
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);
}

View File

@@ -1,28 +0,0 @@
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();
}
}

View File

@@ -1,49 +0,0 @@
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")
);
}
}

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,11 @@ 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 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
@@ -18,9 +20,10 @@ 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(AuthProperties props) { public JwtService(@Value("${jwt.secret.key}") String jwtSecretKey,
this.jwtExpiration = props.expiration().jwt().toMillis(); @Value("${jwt.expiration}") long jwtExpiration) {
this.algorithm = Algorithm.HMAC256(props.jwtSecret()); this.jwtExpiration = jwtExpiration;
this.algorithm = Algorithm.HMAC256(jwtSecretKey);
} }
/** /**
@@ -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

@@ -1,248 +0,0 @@
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);
}
}

View File

@@ -78,8 +78,8 @@ public class MarkerService {
* @return the persisted {@link Marker} * @return the persisted {@link Marker}
* @throws NotStreaming if the user has no active stream to mark * @throws NotStreaming if the user has no active stream to mark
*/ */
public Marker create(String message) { public Marker create(User user, String message) {
Optional<Stream> stream = streamService.getActiveStream(); Optional<Stream> stream = streamService.getActiveStream(user);
if (stream.isEmpty()) { if (stream.isEmpty()) {
throw new NotStreaming("User must be streaming to mark"); throw new NotStreaming("User must be streaming to mark");

View File

@@ -90,19 +90,14 @@ public class StreamService {
} }
/** /**
* Returns the current user's active (not-yet-ended) stream, if any. * Returns the user's active (not-yet-ended) stream, if any.
* *
* @param user the User object
* @return the active {@link Stream}, or empty if the user is not currently streaming * @return the active {@link Stream}, or empty if the user is not currently streaming
* @throws NotAuthenticated if no user session is present * @throws NotAuthenticated if no user session is present
*/ */
public Optional<Stream> getActiveStream() { public Optional<Stream> getActiveStream(User user) {
Optional<User> user = userService.getLoggedInUser(); return streamRepository.findByUserAndEndDateIsNull(user);
if (user.isEmpty()) {
throw new NotAuthenticated("Log in to see user streams");
}
return streamRepository.findByUserAndEndDateIsNull(user.get());
} }
/** /**

View File

@@ -1,44 +1,47 @@
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.ddf.vodsystem.security.TokenService; import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import org.slf4j.Logger; import com.google.api.client.http.javanet.NetHttpTransport;
import org.slf4j.LoggerFactory; import com.google.api.client.json.JsonFactory;
import org.springframework.transaction.annotation.Transactional; import com.google.api.client.json.gson.GsonFactory;
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 static final Logger logger = LoggerFactory.getLogger(UserService.class); private final GoogleIdTokenVerifier verifier;
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,
GoogleVerifierService googleVerifierService, @Value("${google.client.id}") String googleClientId) {
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;
} }
/** /**
@@ -65,58 +68,24 @@ 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 public String login(String idToken) {
public TokenPackage login(String idToken) { GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
GoogleUser googleUser = googleVerifierService.verify(idToken); String googleId = googleIdToken.getPayload().getSubject();
User user = createOrUpdateGoogleUser(googleUser);
String refreshToken = tokenService.createSession(user); if (googleId == null) {
String jwt = jwtService.generateToken(user.getId()); throw new NotAuthenticated("Invalid ID token");
logger.debug("Access and Refresh tokens issued for User ID {}", user.getId());
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) {
TokenService.Rotation rotation = tokenService.rotate(refreshToken);
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);
}
/**
* 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()");
} }
User googleUser = getGoogleUser(googleIdToken);
User user = createOrUpdateUser(googleUser);
return jwtService.generateToken(user.getId());
} }
/** /**
@@ -129,30 +98,57 @@ public class UserService {
return userRepository.findByStreamKey(streamKey); return userRepository.findByStreamKey(streamKey);
} }
private User createOrUpdateGoogleUser(GoogleUser googleUser) { public String regenerateStreamKey(User user) {
Optional<User> existingUser = userRepository.findByGoogleId(googleUser.googleId()); user.setStreamKey(generateStreamKey());
User user; userRepository.saveAndFlush(user);
return user.getStreamKey();
}
private User createOrUpdateUser(User user) {
Optional<User> existingUser = userRepository.findByGoogleId(user.getGoogleId());
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(HexFormat.of().formatHex(generateRandomBytes(24))); user.setStreamKey(generateStreamKey());
} else { return userRepository.saveAndFlush(user);
user = existingUser.get();
} }
user.setEmail(googleUser.email()); User existing = existingUser.get();
user.setName(googleUser.name()); existing.setEmail(user.getEmail());
user.setProfilePictureUrl(googleUser.profilePictureUrl()); existing.setName(user.getName());
return userRepository.save(user); existing.setProfilePictureUrl(user.getProfilePictureUrl());
existing.setUsername(user.getUsername());
return userRepository.saveAndFlush(existing);
} }
private byte[] generateRandomBytes(int length) { private User getGoogleUser(GoogleIdToken idToken) {
byte[] bytes = new byte[length]; String googleId = idToken.getPayload().getSubject();
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 bytes; return HexFormat.of().formatHex(bytes);
} }
} }

View File

@@ -10,22 +10,17 @@ spring.datasource.url=${POSTGRES_URL}
spring.datasource.username=${POSTGRES_USER} spring.datasource.username=${POSTGRES_USER}
spring.datasource.password=${POSTGRES_PASSWORD} spring.datasource.password=${POSTGRES_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=validate spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false spring.jpa.show-sql=false
spring.sql.init.mode=always
spring.flyway.enabled=true spring.sql.init.schema-locations=classpath:db/schema.sql
spring.flyway.locations=classpath:db/migrations
## Security ## Security
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
app.security.jwt-secret=${JWT_SECRET_KEY} jwt.secret.key=${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}

View File

@@ -1,27 +0,0 @@
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);

View File

@@ -1,4 +1,7 @@
-- Users Table -- DROP TABLE IF EXISTS streams;
-- DROP TABLE IF EXISTS clips;
-- DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
google_id VARCHAR(64), google_id VARCHAR(64),
@@ -11,8 +14,6 @@ CREATE TABLE IF NOT EXISTS users (
stream_key VARCHAR(64) NOT NULL UNIQUE stream_key VARCHAR(64) NOT NULL UNIQUE
); );
-- Streams Table
CREATE TABLE IF NOT EXISTS streams ( CREATE TABLE IF NOT EXISTS streams (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT, user_id BIGINT,
@@ -23,7 +24,6 @@ CREATE TABLE IF NOT EXISTS streams (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
-- Clips Table
CREATE TABLE IF NOT EXISTS clips ( CREATE TABLE IF NOT EXISTS clips (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
@@ -40,7 +40,6 @@ CREATE TABLE IF NOT EXISTS clips (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
-- VoDs Table
CREATE TABLE IF NOT EXISTS vods ( CREATE TABLE IF NOT EXISTS vods (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
@@ -57,7 +56,6 @@ CREATE TABLE IF NOT EXISTS vods (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
-- Markers Table
CREATE TABLE IF NOT EXISTS markers ( CREATE TABLE IF NOT EXISTS markers (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,

View File

@@ -1,628 +0,0 @@
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.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 java.security.MessageDigest;
import java.time.*;
import java.util.HexFormat;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class MutableClock extends Clock {
private Instant now;
MutableClock(Instant start) {
this.now = start;
}
void advance(Duration d) {
now = now.plus(d);
}
@Override public Instant instant() { return now; }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
}
@ExtendWith(MockitoExtension.class)
class TokenServiceTest {
@Mock private TokenFamilyRepository tokenFamilyRepository;
@Mock private RefreshTokenRepository refreshTokenRepository;
@Captor ArgumentCaptor<TokenFamily> familyCaptor;
@Captor ArgumentCaptor<RefreshToken> tokenCaptor;
private static final HexFormat HEX = HexFormat.of();
private static final String rawToken = "deadbeefdeadbeefdeadbeefdeadbeef";
AuthProperties authProperties;
TokenService tokenService;
MutableClock clock;
User createUser(Long id, String username, String email) {
User user = new User();
user.setId(id);
user.setUsername(username);
user.setEmail(email);
return user;
}
@BeforeEach
void setUp() {
authProperties = new AuthProperties(
"a-secret",
new AuthProperties.Expiration(
Duration.ofSeconds(10),
Duration.ofSeconds(10),
Duration.ofSeconds(10),
Duration.ofSeconds(5)
)
);
clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
tokenService = new TokenService(
tokenFamilyRepository,
refreshTokenRepository,
authProperties,
clock
);
}
@Nested
@DisplayName("createSession()")
class createSession {
@Test
void createSession_createsTokenFamily() {
User user = createUser(1L, "user", "user@gmail.com");
tokenService.createSession(user);
verify(tokenFamilyRepository).save(familyCaptor.capture());
verify(refreshTokenRepository).save(tokenCaptor.capture());
TokenFamily tokenFamily = familyCaptor.getValue();
RefreshToken refreshToken = tokenCaptor.getValue();
assertSame(tokenFamily.getUser(), user);
assertSame(refreshToken.getTokenFamily(), tokenFamily);
assertEquals(
tokenFamily.getExpiresAt(),
clock.instant().plusMillis(authProperties.expiration().tokenFamily().toMillis())
);
}
@Test
void createSession_savesRefreshTokenCreatedAtAndExpires() {
User user = createUser(1L, "user", "user");
tokenService.createSession(user);
verify(refreshTokenRepository).save(tokenCaptor.capture());
RefreshToken refreshToken = tokenCaptor.getValue();
assertEquals(refreshToken.getCreatedAt(), clock.instant());
assertEquals(
refreshToken.getExpiresAt(),
clock.instant().plusMillis(authProperties.expiration().refreshToken().toMillis())
);
}
@Test
void createSession_returnsHexTokenOf64Characters() {
User user = createUser(1L, "user", "user");
String token = tokenService.createSession(user);
assertEquals(64, token.length());
assertTrue(token.matches("[0-9a-fA-F]+"), () -> "not hex: " + token);
}
@Test
void createSession_doesNotStoreRawToken() {
User user = createUser(1L, "user", "user");
String token = tokenService.createSession(user);
verify(refreshTokenRepository).save(tokenCaptor.capture());
RefreshToken refreshToken = tokenCaptor.getValue();
assertNotEquals(refreshToken.getTokenHash(), HEX.parseHex(token));
}
}
// Rotate
@Nested
@DisplayName("rotate()")
class rotate {
// Input val
@Test
void nullToken_throwsNotAuthenticated() {
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate(null)
);
}
@Test
void blankToken_throwsNotAuthenticated() {
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate("")
);
}
// Invalid cases
@Test
void unknownToken_throwsNotAuthenticated() {
when(refreshTokenRepository.findByHash(any())).thenReturn(
Optional.empty()
);
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate(rawToken)
);
}
@Test
void revokedFamily_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily();
tokenFamily.setRevokedAt(Instant.now(clock));
RefreshToken refreshToken = tokenIn(tokenFamily);
clock.advance(Duration.ofMinutes(1));
when(refreshTokenRepository.findByHash(any())).thenReturn(
Optional.of(refreshToken)
);
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate(rawToken)
);
}
// Happy Path
@Test
void unusedToken_returnsUserIdAndRefreshToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertEquals(1L, rotation.userId());
assertNotEquals(rawToken, rotation.newRefreshToken());
}
@Test
void unsedToken_savesRefreshToken() {
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"));
assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash());
assertEquals(refreshToken.getTokenFamily().getUser(), newToken.getTokenFamily().getUser()); // same owner
assertEquals(refreshToken.getTokenFamily(), newToken.getTokenFamily()); // same rotation family, if you model one
assertNull(newToken.getUsedAt());
}
@Test
void ununsedToken_savesOldTokenWithUsedAt() {
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 savedToken = tokenCaptor.getAllValues().stream()
.filter(t -> t.getUsedAt() != null)
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
.orElseThrow(() -> new AssertionError("used_at field not set in any token"));
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 +0,0 @@
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.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
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);
}
}
}