mirror of
https://github.com/gethomepage/homepage.git
synced 2026-02-07 16:30:52 +08:00
Basic password
This commit is contained in:
@@ -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`)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,16 +20,24 @@ 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) {
|
||||||
|
if (hasOidcConfig) {
|
||||||
providers = [
|
providers = [
|
||||||
{
|
{
|
||||||
id: "homepage-oidc",
|
id: "homepage-oidc",
|
||||||
@@ -52,6 +63,32 @@ if (authEnabled) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
} else {
|
||||||
|
providers = [
|
||||||
|
CredentialsProvider({
|
||||||
|
id: "homepage-password",
|
||||||
|
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({
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
<form
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await signIn("homepage-password", {
|
||||||
|
redirect: true,
|
||||||
|
callbackUrl,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-slate-300">Password</label>
|
||||||
|
<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 →</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{!hasPasswordProvider &&
|
||||||
|
Object.values(providers).map((provider) => (
|
||||||
<button
|
<button
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
type="button"
|
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"
|
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} →</span>
|
<span className="flex items-center justify-center gap-2">Login via {provider.name} →</span>
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user