Basic password

This commit is contained in:
shamoon
2026-02-04 21:29:01 -08:00
parent 6b6457cb5d
commit c4afced5fa
4 changed files with 159 additions and 42 deletions

View File

@@ -29,13 +29,22 @@ You have a few options for deploying homepage, depending on your needs. We offer
### Security & Authentication ### Security & Authentication
Public deployments of Homepage should be secured via a reverse proxy, VPN, or similar. As of version 2.0, Homepage supports a simple OIDC login flow for built-in authorization. Enable it with the following environment variables: Public deployments of Homepage should be secured via a reverse proxy, VPN, or similar. As of version 2.0, Homepage supports a simple authorization gate with a password or OIDC. When enabled, Homepage will use password login by default unless OIDC variables are provided.
Required environment variables for authentication:
- `HOMEPAGE_AUTH_ENABLED=true` - `HOMEPAGE_AUTH_ENABLED=true`
- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies)
For password-only login:
- `HOMEPAGE_AUTH_PASSWORD` (password-only login; required unless OIDC settings are provided)
For OIDC login (overrides password login):
- `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`) - `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`)
- `HOMEPAGE_OIDC_CLIENT_ID` - `HOMEPAGE_OIDC_CLIENT_ID`
- `HOMEPAGE_OIDC_CLIENT_SECRET` - `HOMEPAGE_OIDC_CLIENT_SECRET`
- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies)
- `HOMEPAGE_EXTERNAL_URL` (external URL to your Homepage instance; used for callbacks) - `HOMEPAGE_EXTERNAL_URL` (external URL to your Homepage instance; used for callbacks)
- Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`) - Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`)

View File

@@ -38,14 +38,28 @@ describe("pages/api/auth/[...nextauth]", () => {
expect(mod.default.options.secret).toBe("secret"); expect(mod.default.options.secret).toBe("secret");
}); });
it("throws when auth is enabled but required settings are missing", async () => { it("throws when auth is enabled but no provider settings are present", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_ENABLED = "true";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow( await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/OIDC auth is enabled but required settings are missing/i, /Password auth is enabled but required settings are missing/i,
); );
}); });
it("builds a password provider when auth is enabled without OIDC config", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
const mod = await import("pages/api/auth/[...nextauth]");
const [provider] = mod.default.options.providers;
expect(provider.id).toBe("homepage-password");
expect(provider.name).toBe("Password");
expect(provider.type).toBe("credentials");
expect(typeof provider.authorize).toBe("function");
});
it("builds an OIDC provider when enabled and maps profile fields", async () => { it("builds an OIDC provider when enabled and maps profile fields", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true"; process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example/"; process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example/";
@@ -97,4 +111,14 @@ describe("pages/api/auth/[...nextauth]", () => {
image: null, image: null,
}); });
}); });
it("throws when only partial OIDC settings are provided", async () => {
process.env.HOMEPAGE_AUTH_ENABLED = "true";
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example";
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
/OIDC auth is enabled but required settings are missing/i,
);
});
}); });

View File

@@ -1,4 +1,6 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { timingSafeEqual } from "node:crypto";
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED); const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
const issuer = process.env.HOMEPAGE_OIDC_ISSUER; const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
@@ -6,6 +8,7 @@ const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET; const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET;
const homepageAuthSecret = process.env.HOMEPAGE_AUTH_SECRET; const homepageAuthSecret = process.env.HOMEPAGE_AUTH_SECRET;
const homepageExternalUrl = process.env.HOMEPAGE_EXTERNAL_URL; const homepageExternalUrl = process.env.HOMEPAGE_EXTERNAL_URL;
const homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD;
// Map HOMEPAGE_* envs to what NextAuth expects // Map HOMEPAGE_* envs to what NextAuth expects
if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) { if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) {
@@ -17,41 +20,75 @@ if (!process.env.NEXTAUTH_URL && homepageExternalUrl) {
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile"; const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer; const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
const hasOidcConfig = Boolean(issuer && clientId && clientSecret);
const hasAnyOidcConfig = Boolean(issuer || clientId || clientSecret);
if ( if (authEnabled) {
authEnabled && if (hasOidcConfig) {
(!issuer || !clientId || !clientSecret || !process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) if (!process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) {
) { throw new Error("OIDC auth is enabled but required settings are missing.");
throw new Error("OIDC auth is enabled but required settings are missing."); }
} else if (hasAnyOidcConfig) {
throw new Error("OIDC auth is enabled but required settings are missing.");
} else if (!homepageAuthPassword || !process.env.NEXTAUTH_SECRET) {
throw new Error("Password auth is enabled but required settings are missing.");
}
} }
let providers = []; let providers = [];
if (authEnabled) { if (authEnabled) {
providers = [ if (hasOidcConfig) {
{ providers = [
id: "homepage-oidc", {
name: process.env.HOMEPAGE_OIDC_NAME || "Homepage OIDC", id: "homepage-oidc",
type: "oauth", name: process.env.HOMEPAGE_OIDC_NAME || "Homepage OIDC",
idToken: true, type: "oauth",
issuer: cleanedIssuer, idToken: true,
wellKnown: `${cleanedIssuer}/.well-known/openid-configuration`, issuer: cleanedIssuer,
clientId, wellKnown: `${cleanedIssuer}/.well-known/openid-configuration`,
clientSecret, clientId,
authorization: { clientSecret,
params: { authorization: {
scope: defaultScope, params: {
scope: defaultScope,
},
},
profile(profile) {
return {
id: profile.sub ?? profile.id ?? profile.user_id ?? profile.uid ?? profile.email,
name: profile.name ?? profile.preferred_username ?? profile.nickname ?? profile.email,
email: profile.email ?? null,
image: profile.picture ?? null,
};
}, },
}, },
profile(profile) { ];
return { } else {
id: profile.sub ?? profile.id ?? profile.user_id ?? profile.uid ?? profile.email, providers = [
name: profile.name ?? profile.preferred_username ?? profile.nickname ?? profile.email, CredentialsProvider({
email: profile.email ?? null, id: "homepage-password",
image: profile.picture ?? null, name: "Password",
}; credentials: {
}, password: { label: "Password", type: "password" },
}, },
]; async authorize(credentials) {
const provided = credentials?.password ?? "";
const expected = homepageAuthPassword ?? "";
if (!expected || provided.length !== expected.length) {
return null;
}
const isMatch = timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!isMatch) {
return null;
}
return {
id: "homepage",
name: "Homepage",
};
},
}),
];
}
} }
export default NextAuth({ export default NextAuth({

View File

@@ -1,14 +1,22 @@
import classNames from "classnames"; import classNames from "classnames";
import { getProviders } from "next-auth/react"; import { getProviders, signIn } from "next-auth/react";
import { useEffect } from "react"; import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import { BiShieldQuarter } from "react-icons/bi"; import { BiShieldQuarter } from "react-icons/bi";
import { getSettings } from "utils/config/config"; import { getSettings } from "utils/config/config";
export default function SignIn({ providers, settings }) { export default function SignIn({ providers, settings }) {
const router = useRouter();
const [password, setPassword] = useState("");
const theme = settings?.theme || "dark"; const theme = settings?.theme || "dark";
const color = settings?.color || "slate"; const color = settings?.color || "slate";
const title = settings?.title || "Homepage"; const title = settings?.title || "Homepage";
const callbackUrl = useMemo(() => {
const value = router.query?.callbackUrl;
return typeof value === "string" ? value : "/";
}, [router.query?.callbackUrl]);
const error = router.query?.error;
let backgroundImage = ""; let backgroundImage = "";
let opacity = settings?.backgroundOpacity ?? 0; let opacity = settings?.backgroundOpacity ?? 0;
@@ -94,6 +102,8 @@ export default function SignIn({ providers, settings }) {
); );
} }
const hasPasswordProvider = Boolean(providers?.["homepage-password"]);
return ( return (
<> <>
{backgroundImage && ( {backgroundImage && (
@@ -132,16 +142,53 @@ export default function SignIn({ providers, settings }) {
<div className="rounded-2xl border border-white/60 bg-white/70 p-6 shadow-lg shadow-black/5 dark:border-white/10 dark:bg-slate-900/70"> <div className="rounded-2xl border border-white/60 bg-white/70 p-6 shadow-lg shadow-black/5 dark:border-white/10 dark:bg-slate-900/70">
<h2 className="text-lg font-semibold text-gray-900 dark:text-slate-100">Sign in</h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-slate-100">Sign in</h2>
<div className="mt-6 space-y-3"> <div className="mt-6 space-y-3">
{Object.values(providers).map((provider) => ( {hasPasswordProvider && (
<button <form
key={provider.id} className="space-y-3"
type="button" onSubmit={async (event) => {
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500" event.preventDefault();
await signIn("homepage-password", {
redirect: true,
callbackUrl,
password,
});
}}
> >
<span className="flex items-center justify-center gap-2">Login via {provider.name} &rarr;</span> <label className="block text-sm font-medium text-gray-700 dark:text-slate-300">Password</label>
</button> <input
))} type="password"
name="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
className="w-full rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-gray-900 shadow-sm outline-none ring-0 transition focus:border-theme-500 focus:ring-2 focus:ring-theme-500/30 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-100"
required
/>
<button
type="submit"
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
>
<span className="flex items-center justify-center gap-2">Sign in &rarr;</span>
</button>
</form>
)}
{!hasPasswordProvider &&
Object.values(providers).map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => signIn(provider.id, { callbackUrl })}
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
>
<span className="flex items-center justify-center gap-2">Login via {provider.name} &rarr;</span>
</button>
))}
</div> </div>
{hasPasswordProvider && error && (
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800/60 dark:bg-red-950/40 dark:text-red-200">
Invalid password. Please try again.
</p>
)}
</div> </div>
</section> </section>
</div> </div>