Compare commits
1 Commits
master
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
| 13869c577b |
@@ -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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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
10
pom.xml
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
) {}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.ddf.vodsystem.controllers.dto;
|
||||||
|
|
||||||
|
public record StreamKey (String streamKey) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.ddf.vodsystem.dto;
|
|
||||||
|
|
||||||
public record GoogleUser(
|
|
||||||
String googleId,
|
|
||||||
String email,
|
|
||||||
String name,
|
|
||||||
String profilePictureUrl
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.ddf.vodsystem.dto;
|
|
||||||
|
|
||||||
public record TokenPackage (
|
|
||||||
String refreshToken,
|
|
||||||
String accessToken
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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,
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user