Merge pull request #5109 from piotrfx/develop

Add TOTP-based two-factor authentication
This commit is contained in:
jc21
2026-01-13 14:30:48 +10:00
committed by GitHub
16 changed files with 1496 additions and 72 deletions

View File

@@ -1,9 +1,22 @@
import * as api from "./base";
import type { TokenResponse } from "./responseTypes";
import type { TokenResponse, TwoFactorChallengeResponse } from "./responseTypes";
export async function getToken(identity: string, secret: string): Promise<TokenResponse> {
export type LoginResponse = TokenResponse | TwoFactorChallengeResponse;
export function isTwoFactorChallenge(response: LoginResponse): response is TwoFactorChallengeResponse {
return "requires2fa" in response && response.requires2fa === true;
}
export async function getToken(identity: string, secret: string): Promise<LoginResponse> {
return await api.post({
url: "/tokens",
data: { identity, secret },
});
}
export async function verify2FA(challengeToken: string, code: string): Promise<TokenResponse> {
return await api.post({
url: "/tokens/2fa",
data: { challengeToken, code },
});
}

View File

@@ -60,3 +60,4 @@ export * from "./updateStream";
export * from "./updateUser";
export * from "./uploadCertificate";
export * from "./validateCertificate";
export * from "./twoFactor";

View File

@@ -25,3 +25,22 @@ export interface VersionCheckResponse {
latest: string | null;
updateAvailable: boolean;
}
export interface TwoFactorChallengeResponse {
requires2fa: boolean;
challengeToken: string;
}
export interface TwoFactorStatusResponse {
enabled: boolean;
backupCodesRemaining: number;
}
export interface TwoFactorSetupResponse {
secret: string;
otpauthUrl: string;
}
export interface TwoFactorEnableResponse {
backupCodes: string[];
}

View File

@@ -0,0 +1,58 @@
import { camelizeKeys, decamelizeKeys } from "humps";
import AuthStore from "src/modules/AuthStore";
import type {
TwoFactorEnableResponse,
TwoFactorSetupResponse,
TwoFactorStatusResponse,
} from "./responseTypes";
import * as api from "./base";
export async function get2FAStatus(userId: number | "me"): Promise<TwoFactorStatusResponse> {
return await api.get({
url: `/users/${userId}/2fa`,
});
}
export async function start2FASetup(userId: number | "me"): Promise<TwoFactorSetupResponse> {
return await api.post({
url: `/users/${userId}/2fa/setup`,
});
}
export async function enable2FA(userId: number | "me", code: string): Promise<TwoFactorEnableResponse> {
return await api.put({
url: `/users/${userId}/2fa/enable`,
data: { code },
});
}
export async function disable2FA(userId: number | "me", code: string): Promise<{ success: boolean }> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (AuthStore.token) {
headers.Authorization = `Bearer ${AuthStore.token.token}`;
}
const response = await fetch(`/api/users/${userId}/2fa`, {
method: "DELETE",
headers,
body: JSON.stringify(decamelizeKeys({ code })),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error?.messageI18n || payload.error?.message || "Failed to disable 2FA");
}
return camelizeKeys(payload) as { success: boolean };
}
export async function regenerateBackupCodes(
userId: number | "me",
code: string,
): Promise<TwoFactorEnableResponse> {
return await api.post({
url: `/users/${userId}/2fa/backup-codes`,
data: { code },
});
}

View File

@@ -1,9 +1,9 @@
import { IconLock, IconLogout, IconUser } from "@tabler/icons-react";
import { IconLock, IconLogout, IconShieldLock, IconUser } from "@tabler/icons-react";
import { LocalePicker, NavLink, ThemeSwitcher } from "src/components";
import { useAuthState } from "src/context";
import { useUser } from "src/hooks";
import { T } from "src/locale";
import { showChangePasswordModal, showUserModal } from "src/modals";
import { showChangePasswordModal, showTwoFactorModal, showUserModal } from "src/modals";
import styles from "./SiteHeader.module.css";
export function SiteHeader() {
@@ -108,6 +108,17 @@ export function SiteHeader() {
<IconLock width={18} />
<T id="user.change-password" />
</a>
<a
href="?"
className="dropdown-item"
onClick={(e) => {
e.preventDefault();
showTwoFactorModal("me");
}}
>
<IconShieldLock width={18} />
<T id="user.two-factor" />
</a>
<div className="dropdown-divider" />
<a
href="?"

View File

@@ -1,13 +1,28 @@
import { useQueryClient } from "@tanstack/react-query";
import { createContext, type ReactNode, useContext, useState } from "react";
import { useIntervalWhen } from "rooks";
import { getToken, loginAsUser, refreshToken, type TokenResponse } from "src/api/backend";
import {
getToken,
isTwoFactorChallenge,
loginAsUser,
refreshToken,
verify2FA,
type TokenResponse,
} from "src/api/backend";
import AuthStore from "src/modules/AuthStore";
// 2FA challenge state
export interface TwoFactorChallenge {
challengeToken: string;
}
// Context
export interface AuthContextType {
authenticated: boolean;
twoFactorChallenge: TwoFactorChallenge | null;
login: (username: string, password: string) => Promise<void>;
verifyTwoFactor: (code: string) => Promise<void>;
cancelTwoFactor: () => void;
loginAs: (id: number) => Promise<void>;
logout: () => void;
token?: string;
@@ -24,17 +39,35 @@ interface Props {
function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) {
const queryClient = useQueryClient();
const [authenticated, setAuthenticated] = useState(AuthStore.hasActiveToken());
const [twoFactorChallenge, setTwoFactorChallenge] = useState<TwoFactorChallenge | null>(null);
const handleTokenUpdate = (response: TokenResponse) => {
AuthStore.set(response);
setAuthenticated(true);
setTwoFactorChallenge(null);
};
const login = async (identity: string, secret: string) => {
const response = await getToken(identity, secret);
if (isTwoFactorChallenge(response)) {
setTwoFactorChallenge({ challengeToken: response.challengeToken });
return;
}
handleTokenUpdate(response);
};
const verifyTwoFactor = async (code: string) => {
if (!twoFactorChallenge) {
throw new Error("No 2FA challenge pending");
}
const response = await verify2FA(twoFactorChallenge.challengeToken, code);
handleTokenUpdate(response);
};
const cancelTwoFactor = () => {
setTwoFactorChallenge(null);
};
const loginAs = async (id: number) => {
const response = await loginAsUser(id);
AuthStore.add(response);
@@ -69,7 +102,15 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props)
true,
);
const value = { authenticated, login, logout, loginAs };
const value = {
authenticated,
twoFactorChallenge,
login,
verifyTwoFactor,
cancelTwoFactor,
loginAs,
logout,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

View File

@@ -1,4 +1,61 @@
{
"2fa.backup-codes-remaining": {
"defaultMessage": "Backup codes remaining: {count}"
},
"2fa.backup-warning": {
"defaultMessage": "Save these backup codes in a secure place. Each code can only be used once."
},
"2fa.disable": {
"defaultMessage": "Disable Two-Factor Authentication"
},
"2fa.disable-confirm": {
"defaultMessage": "Disable 2FA"
},
"2fa.disable-warning": {
"defaultMessage": "Disabling two-factor authentication will make your account less secure."
},
"2fa.disabled": {
"defaultMessage": "Disabled"
},
"2fa.done": {
"defaultMessage": "I have saved my backup codes"
},
"2fa.enable": {
"defaultMessage": "Enable Two-Factor Authentication"
},
"2fa.enabled": {
"defaultMessage": "Enabled"
},
"2fa.enter-code": {
"defaultMessage": "Enter verification code"
},
"2fa.enter-code-disable": {
"defaultMessage": "Enter verification code to disable"
},
"2fa.regenerate": {
"defaultMessage": "Regenerate"
},
"2fa.regenerate-backup": {
"defaultMessage": "Regenerate Backup Codes"
},
"2fa.regenerate-instructions": {
"defaultMessage": "Enter a verification code to generate new backup codes. Your old codes will be invalidated."
},
"2fa.secret-key": {
"defaultMessage": "Secret Key"
},
"2fa.setup-instructions": {
"defaultMessage": "Scan this QR code with your authenticator app, or enter the secret manually."
},
"2fa.status": {
"defaultMessage": "Status"
},
"2fa.title": {
"defaultMessage": "Two-Factor Authentication"
},
"2fa.verify-enable": {
"defaultMessage": "Verify and Enable"
},
"access-list": {
"defaultMessage": "Access List"
},
@@ -398,6 +455,21 @@
"loading": {
"defaultMessage": "Loading…"
},
"login.2fa-code": {
"defaultMessage": "Verification Code"
},
"login.2fa-code-placeholder": {
"defaultMessage": "Enter code"
},
"login.2fa-description": {
"defaultMessage": "Enter the code from your authenticator app"
},
"login.2fa-title": {
"defaultMessage": "Two-Factor Authentication"
},
"login.2fa-verify": {
"defaultMessage": "Verify"
},
"login.title": {
"defaultMessage": "Login to your account"
},
@@ -686,6 +758,9 @@
"user.switch-light": {
"defaultMessage": "Switch to Light mode"
},
"user.two-factor": {
"defaultMessage": "Two-Factor Auth"
},
"username": {
"defaultMessage": "Username"
},

View File

@@ -0,0 +1,368 @@
import EasyModal, { type InnerModalProps } from "ez-modal-react";
import { Field, Form, Formik } from "formik";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import {
disable2FA,
enable2FA,
get2FAStatus,
regenerateBackupCodes,
start2FASetup,
} from "src/api/backend";
import { Button } from "src/components";
import { T } from "src/locale";
import { validateString } from "src/modules/Validations";
type Step = "loading" | "status" | "setup" | "verify" | "backup" | "disable";
const showTwoFactorModal = (id: number | "me") => {
EasyModal.show(TwoFactorModal, { id });
};
interface Props extends InnerModalProps {
id: number | "me";
}
const TwoFactorModal = EasyModal.create(({ id, visible, remove }: Props) => {
const [error, setError] = useState<ReactNode | null>(null);
const [step, setStep] = useState<Step>("loading");
const [isEnabled, setIsEnabled] = useState(false);
const [backupCodesRemaining, setBackupCodesRemaining] = useState(0);
const [setupData, setSetupData] = useState<{ secret: string; otpauthUrl: string } | null>(null);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const loadStatus = useCallback(async () => {
try {
const status = await get2FAStatus(id);
setIsEnabled(status.enabled);
setBackupCodesRemaining(status.backupCodesRemaining);
setStep("status");
} catch (err: any) {
setError(err.message || "Failed to load 2FA status");
setStep("status");
}
}, [id]);
useEffect(() => {
loadStatus();
}, [loadStatus]);
const handleStartSetup = async () => {
setError(null);
setIsSubmitting(true);
try {
const data = await start2FASetup(id);
setSetupData(data);
setStep("setup");
} catch (err: any) {
setError(err.message || "Failed to start 2FA setup");
}
setIsSubmitting(false);
};
const handleVerify = async (values: { code: string }) => {
setError(null);
setIsSubmitting(true);
try {
const result = await enable2FA(id, values.code);
setBackupCodes(result.backupCodes);
setStep("backup");
} catch (err: any) {
setError(err.message || "Failed to enable 2FA");
}
setIsSubmitting(false);
};
const handleDisable = async (values: { code: string }) => {
setError(null);
setIsSubmitting(true);
try {
await disable2FA(id, values.code);
setIsEnabled(false);
setStep("status");
} catch (err: any) {
setError(err.message || "Failed to disable 2FA");
}
setIsSubmitting(false);
};
const handleRegenerateBackup = async (values: { code: string }) => {
setError(null);
setIsSubmitting(true);
try {
const result = await regenerateBackupCodes(id, values.code);
setBackupCodes(result.backupCodes);
setStep("backup");
} catch (err: any) {
setError(err.message || "Failed to regenerate backup codes");
}
setIsSubmitting(false);
};
const handleBackupDone = () => {
setIsEnabled(true);
setBackupCodes([]);
loadStatus();
};
const renderContent = () => {
if (step === "loading") {
return (
<div className="text-center py-4">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
);
}
if (step === "status") {
return (
<div className="py-2">
<div className="mb-4">
<div className="d-flex align-items-center justify-content-between mb-2">
<span className="fw-bold">
<T id="2fa.status" />
</span>
<span className={`badge text-white ${isEnabled ? "bg-success" : "bg-secondary"}`}>
{isEnabled ? <T id="2fa.enabled" /> : <T id="2fa.disabled" />}
</span>
</div>
{isEnabled && (
<p className="text-muted small mb-0">
<T id="2fa.backup-codes-remaining" data={{ count: backupCodesRemaining }} />
</p>
)}
</div>
{!isEnabled ? (
<Button
fullWidth
color="azure"
onClick={handleStartSetup}
isLoading={isSubmitting}
>
<T id="2fa.enable" />
</Button>
) : (
<div className="d-flex flex-column gap-2">
<Button fullWidth onClick={() => setStep("disable")}>
<T id="2fa.disable" />
</Button>
<Button fullWidth onClick={() => setStep("verify")}>
<T id="2fa.regenerate-backup" />
</Button>
</div>
)}
</div>
);
}
if (step === "setup" && setupData) {
return (
<div className="py-2">
<p className="text-muted mb-3">
<T id="2fa.setup-instructions" />
</p>
<div className="text-center mb-3">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(setupData.otpauthUrl)}`}
alt="QR Code"
className="img-fluid"
style={{ maxWidth: "200px" }}
/>
</div>
<label className="mb-3 d-block">
<span className="form-label small text-muted">
<T id="2fa.secret-key" />
</span>
<input
type="text"
className="form-control font-monospace"
value={setupData.secret}
readOnly
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
</label>
<Formik initialValues={{ code: "" }} onSubmit={handleVerify}>
{() => (
<Form>
<Field name="code" validate={validateString(6, 6)}>
{({ field, form }: any) => (
<label className="mb-3 d-block">
<span className="form-label">
<T id="2fa.enter-code" />
</span>
<input
{...field}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
placeholder="000000"
maxLength={6}
/>
<div className="invalid-feedback">{form.errors.code}</div>
</label>
)}
</Field>
<div className="d-flex gap-2">
<Button
type="button"
fullWidth
onClick={() => setStep("status")}
disabled={isSubmitting}
>
<T id="cancel" />
</Button>
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
<T id="2fa.verify-enable" />
</Button>
</div>
</Form>
)}
</Formik>
</div>
);
}
if (step === "backup") {
return (
<div className="py-2">
<Alert variant="warning">
<T id="2fa.backup-warning" />
</Alert>
<div className="mb-3">
<div className="row g-2">
{backupCodes.map((code, index) => (
<div key={index} className="col-6">
<code className="d-block p-2 bg-light rounded text-center">{code}</code>
</div>
))}
</div>
</div>
<Button fullWidth color="azure" onClick={handleBackupDone}>
<T id="2fa.done" />
</Button>
</div>
);
}
if (step === "disable") {
return (
<div className="py-2">
<Alert variant="warning">
<T id="2fa.disable-warning" />
</Alert>
<Formik initialValues={{ code: "" }} onSubmit={handleDisable}>
{() => (
<Form>
<Field name="code" validate={validateString(6, 6)}>
{({ field, form }: any) => (
<label className="mb-3 d-block">
<span className="form-label">
<T id="2fa.enter-code-disable" />
</span>
<input
{...field}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
placeholder="000000"
maxLength={6}
/>
<div className="invalid-feedback">{form.errors.code}</div>
</label>
)}
</Field>
<div className="d-flex gap-2">
<Button
type="button"
fullWidth
onClick={() => setStep("status")}
disabled={isSubmitting}
>
<T id="cancel" />
</Button>
<Button type="submit" fullWidth color="red" isLoading={isSubmitting}>
<T id="2fa.disable-confirm" />
</Button>
</div>
</Form>
)}
</Formik>
</div>
);
}
if (step === "verify") {
return (
<div className="py-2">
<p className="text-muted mb-3">
<T id="2fa.regenerate-instructions" />
</p>
<Formik initialValues={{ code: "" }} onSubmit={handleRegenerateBackup}>
{() => (
<Form>
<Field name="code" validate={validateString(6, 6)}>
{({ field, form }: any) => (
<label className="mb-3 d-block">
<span className="form-label">
<T id="2fa.enter-code" />
</span>
<input
{...field}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
placeholder="000000"
maxLength={6}
/>
<div className="invalid-feedback">{form.errors.code}</div>
</label>
)}
</Field>
<div className="d-flex gap-2">
<Button
type="button"
fullWidth
onClick={() => setStep("status")}
disabled={isSubmitting}
>
<T id="cancel" />
</Button>
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
<T id="2fa.regenerate" />
</Button>
</div>
</Form>
)}
</Formik>
</div>
);
}
return null;
};
return (
<Modal show={visible} onHide={remove}>
<Modal.Header closeButton>
<Modal.Title>
<T id="2fa.title" />
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
{error}
</Alert>
{renderContent()}
</Modal.Body>
</Modal>
);
});
export { showTwoFactorModal };

View File

@@ -13,4 +13,5 @@ export * from "./RedirectionHostModal";
export * from "./RenewCertificateModal";
export * from "./SetPasswordModal";
export * from "./StreamModal";
export * from "./TwoFactorModal";
export * from "./UserModal";

View File

@@ -8,8 +8,77 @@ import { intl, T } from "src/locale";
import { validateEmail, validateString } from "src/modules/Validations";
import styles from "./index.module.css";
export default function Login() {
const emailRef = useRef(null);
function TwoFactorForm() {
const codeRef = useRef<HTMLInputElement>(null);
const [formErr, setFormErr] = useState("");
const { verifyTwoFactor, cancelTwoFactor } = useAuthState();
const onSubmit = async (values: any, { setSubmitting }: any) => {
setFormErr("");
try {
await verifyTwoFactor(values.code);
} catch (err) {
if (err instanceof Error) {
setFormErr(err.message);
}
}
setSubmitting(false);
};
useEffect(() => {
codeRef.current?.focus();
}, []);
return (
<>
<h2 className="h2 text-center mb-4">
<T id="login.2fa-title" />
</h2>
<p className="text-secondary text-center mb-4">
<T id="login.2fa-description" />
</p>
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
<Formik initialValues={{ code: "" }} onSubmit={onSubmit}>
{({ isSubmitting }) => (
<Form>
<div className="mb-3">
<Field name="code" validate={validateString(6, 20)}>
{({ field, form }: any) => (
<label className="form-label">
<T id="login.2fa-code" />
<input
{...field}
ref={codeRef}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
required
maxLength={20}
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "login.2fa-code-placeholder" })}
/>
<div className="invalid-feedback">{form.errors.code}</div>
</label>
)}
</Field>
</div>
<div className="form-footer d-flex gap-2">
<Button type="button" fullWidth onClick={cancelTwoFactor} disabled={isSubmitting}>
<T id="cancel" />
</Button>
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
<T id="login.2fa-verify" />
</Button>
</div>
</Form>
)}
</Formik>
</>
);
}
function LoginForm() {
const emailRef = useRef<HTMLInputElement>(null);
const [formErr, setFormErr] = useState("");
const { login } = useAuthState();
@@ -26,10 +95,79 @@ export default function Login() {
};
useEffect(() => {
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
emailRef.current.focus();
emailRef.current?.focus();
}, []);
return (
<>
<h2 className="h2 text-center mb-4">
<T id="login.title" />
</h2>
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
<Formik
initialValues={
{
email: "",
password: "",
} as any
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
<Form>
<div className="mb-3">
<Field name="email" validate={validateEmail()}>
{({ field, form }: any) => (
<label className="form-label">
<T id="email-address" />
<input
{...field}
ref={emailRef}
type="email"
required
className={`form-control ${form.errors.email && form.touched.email ? " is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "email-address" })}
/>
<div className="invalid-feedback">{form.errors.email}</div>
</label>
)}
</Field>
</div>
<div className="mb-2">
<Field name="password" validate={validateString(8, 255)}>
{({ field, form }: any) => (
<>
<label className="form-label">
<T id="password" />
<input
{...field}
type="password"
autoComplete="current-password"
required
maxLength={255}
className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "password" })}
/>
<div className="invalid-feedback">{form.errors.password}</div>
</label>
</>
)}
</Field>
</div>
<div className="form-footer">
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
<T id="sign-in" />
</Button>
</div>
</Form>
)}
</Formik>
</>
);
}
export default function Login() {
const { twoFactorChallenge } = useAuthState();
const health = useHealth();
const getVersion = () => {
@@ -56,68 +194,7 @@ export default function Login() {
</div>
<div className="card card-md">
<div className="card-body">
<h2 className="h2 text-center mb-4">
<T id="login.title" />
</h2>
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
<Formik
initialValues={
{
email: "",
password: "",
} as any
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
<Form>
<div className="mb-3">
<Field name="email" validate={validateEmail()}>
{({ field, form }: any) => (
<label className="form-label">
<T id="email-address" />
<input
{...field}
ref={emailRef}
type="email"
required
className={`form-control ${form.errors.email && form.touched.email ? " is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "email-address" })}
/>
<div className="invalid-feedback">{form.errors.email}</div>
</label>
)}
</Field>
</div>
<div className="mb-2">
<Field name="password" validate={validateString(8, 255)}>
{({ field, form }: any) => (
<>
<label className="form-label">
<T id="password" />
<input
{...field}
type="password"
autoComplete="current-password"
required
maxLength={255}
className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "password" })}
/>
<div className="invalid-feedback">{form.errors.password}</div>
</label>
</>
)}
</Field>
</div>
<div className="form-footer">
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
<T id="sign-in" />
</Button>
</div>
</Form>
)}
</Formik>
{twoFactorChallenge ? <TwoFactorForm /> : <LoginForm />}
</div>
</div>
<div className="text-center text-secondary mt-3">{getVersion()}</div>