-
-
Notifications
You must be signed in to change notification settings - Fork 97
LDAP support #473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
LDAP support #473
Changes from 7 commits
edd6e67
7c3d40f
f0e8810
43fb07a
0276948
35e2456
28ff1f6
959965e
16af868
7ff3536
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,22 +10,22 @@ export const dynamic = "force-dynamic"; | |
|
|
||
| export default async function LoginPage() { | ||
| const t = await getTranslations("auth"); | ||
| const ssoEnabled = process.env.SSO_MODE === "oidc"; | ||
| const ssoIsOidc = process.env.SSO_MODE === "oidc"; | ||
| const allowLocal = isEnvEnabled(process.env.SSO_FALLBACK_LOCAL); | ||
|
|
||
| const hasExistingUsers = await hasUsers(); | ||
| if ((!hasExistingUsers && !ssoEnabled) || (!hasExistingUsers && allowLocal)) { | ||
| if (!hasExistingUsers && (!process.env.SSO_MODE || allowLocal)) { | ||
|
||
| redirect("/auth/setup"); | ||
| } | ||
|
|
||
| if (ssoEnabled && !allowLocal) { | ||
| if (ssoIsOidc && !allowLocal) { | ||
| return <SsoOnlyLogin />; | ||
| } | ||
|
|
||
| return ( | ||
| <AuthShell> | ||
| <div className="space-y-6"> | ||
| <LoginForm ssoEnabled={ssoEnabled} /> | ||
| <LoginForm ssoEnabled={ssoIsOidc} /> | ||
| </div> | ||
| </AuthShell> | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,8 +23,9 @@ import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists"; | |
| import fs from "fs/promises"; | ||
| import { CHECKLISTS_DIR, NOTES_DIR, USERS_FILE } from "@/app/_consts/files"; | ||
| import { logAuthEvent } from "../log"; | ||
| import { getUsername } from "../users"; | ||
| import { getUsername, ensureUser } from "../users"; | ||
| import { isEnvEnabled } from "@/app/_utils/env-utils"; | ||
| import { ldapLogin } from "./ldap"; | ||
|
|
||
| interface User { | ||
| username: string; | ||
|
|
@@ -40,7 +41,7 @@ const hashPassword = (password: string): string => { | |
| }; | ||
|
|
||
| /** | ||
| * 🧙♂️ | ||
| * �♂️ | ||
|
||
| */ | ||
| const _youShallNotPass = (attempts: number): number => { | ||
| if (attempts <= 3) return 0; | ||
|
|
@@ -143,6 +144,8 @@ export const login = async (formData: FormData) => { | |
|
|
||
| const usersFile = path.join(process.cwd(), "data", "users", "users.json"); | ||
| await lock(usersFile); | ||
| // fccview is onto you! | ||
|
||
| let lockReleased = false; | ||
|
|
||
| try { | ||
| const users = await readJsonFile(USERS_FILE); | ||
|
|
@@ -176,6 +179,96 @@ export const login = async (formData: FormData) => { | |
| } | ||
| } | ||
|
|
||
| if (process.env.SSO_MODE === "ldap") { | ||
| const ldapResult = await ldapLogin(username, password); | ||
|
|
||
| if (!ldapResult.ok) { | ||
| if (ldapResult.kind === "connection_error") { | ||
| await logAuthEvent("login", username, false, "LDAP connection error"); | ||
| return { error: "Authentication service unavailable" }; | ||
| } | ||
|
|
||
| if (ldapResult.kind === "unauthorized") { | ||
| lockReleased = true; | ||
| await unlock(usersFile); | ||
| redirect("/auth/login?error=unauthorized"); | ||
| } | ||
|
|
||
| // invalid_credentials — increment brute-force counter if user exists locally | ||
| if (user && !bruteforceProtectionDisabled) { | ||
|
||
| const userIndex = users.findIndex( | ||
| (u: User) => u.username.toLowerCase() === username.toLowerCase() | ||
| ); | ||
|
|
||
| if (userIndex !== -1) { | ||
| const failedAttempts = (users[userIndex].failedLoginAttempts || 0) + 1; | ||
| users[userIndex].failedLoginAttempts = failedAttempts; | ||
|
|
||
| const delayMs = _youShallNotPass(failedAttempts); | ||
| let lockedUntil: string | undefined; | ||
| if (delayMs > 0) { | ||
| lockedUntil = new Date(Date.now() + delayMs).toISOString(); | ||
| users[userIndex].nextAllowedLoginAttempt = lockedUntil; | ||
| } else { | ||
| users[userIndex].nextAllowedLoginAttempt = undefined; | ||
| } | ||
|
|
||
| await writeJsonFile(users, USERS_FILE); | ||
|
|
||
| await logAuthEvent( | ||
| "login", | ||
| username, | ||
| false, | ||
| `Invalid credentials - attempt ${failedAttempts}` | ||
| ); | ||
|
|
||
| const attemptsRemaining = Math.max(0, 4 - failedAttempts); | ||
| const waitSeconds = delayMs > 0 ? Math.ceil(delayMs / 1000) : 0; | ||
|
|
||
| return { | ||
| error: delayMs > 0 ? "Too many failed attempts" : "Invalid username or password", | ||
| attemptsRemaining, | ||
| failedAttempts, | ||
| ...(lockedUntil && { lockedUntil, waitSeconds }), | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| await logAuthEvent("login", username, false, "Invalid username or password"); | ||
| return { error: "Invalid username or password" }; | ||
| } | ||
|
|
||
| // LDAP success — release lock before ensureUser to avoid deadlock | ||
| lockReleased = true; | ||
| await unlock(usersFile); | ||
|
|
||
| await ensureUser(ldapResult.username, ldapResult.isAdmin); | ||
|
|
||
| const ldapSessionId = createHash("sha256") | ||
| .update(Math.random().toString()) | ||
| .digest("hex"); | ||
|
|
||
| await createSession(ldapSessionId, ldapResult.username, "ldap"); | ||
|
|
||
| const ldapCookieName = | ||
| process.env.NODE_ENV === "production" && process.env.HTTPS === "true" | ||
| ? "__Host-session" | ||
| : "session"; | ||
|
|
||
| (await cookies()).set(ldapCookieName, ldapSessionId, { | ||
| httpOnly: true, | ||
| secure: | ||
| process.env.NODE_ENV === "production" && process.env.HTTPS === "true", | ||
| sameSite: "lax", | ||
| maxAge: 30 * 24 * 60 * 60, | ||
| path: "/", | ||
| }); | ||
|
|
||
| await logAuthEvent("login", ldapResult.username, true); | ||
|
|
||
| redirect("/"); | ||
| } | ||
|
|
||
| if (!user || user.passwordHash !== hashPassword(password)) { | ||
| if (user && !bruteforceProtectionDisabled) { | ||
| const userIndex = users.findIndex( | ||
|
|
@@ -293,7 +386,9 @@ export const login = async (formData: FormData) => { | |
|
|
||
| redirect("/"); | ||
| } finally { | ||
| await unlock(usersFile); | ||
| if (!lockReleased) { | ||
| await unlock(usersFile); | ||
| } | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should probably double-check that this change is OK |
||
| } | ||
| }; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| import { Client } from "ldapts"; | ||
| import { getEnvOrFile } from "@/app/_server/actions/file"; | ||
|
|
||
| export type LdapLoginResult = | ||
| | { ok: true; username: string; isAdmin: boolean } | ||
| | { ok: false; kind: "invalid_credentials" } | ||
| | { ok: false; kind: "unauthorized" } | ||
| | { ok: false; kind: "connection_error" }; | ||
|
|
||
| function escapeLdapFilter(value: string): string { | ||
| return value | ||
| .replace(/\\/g, "\\5c") | ||
| .replace(/\*/g, "\\2a") | ||
| .replace(/\(/g, "\\28") | ||
| .replace(/\)/g, "\\29") | ||
| .replace(/\0/g, "\\00"); | ||
| } | ||
|
|
||
| export async function ldapLogin( | ||
| username: string, | ||
| password: string | ||
| ): Promise<LdapLoginResult> { | ||
| const url = process.env.LDAP_URL; | ||
| const bindDN = process.env.LDAP_BIND_DN; | ||
| const bindPassword = await getEnvOrFile( | ||
| "LDAP_BIND_PASSWORD", | ||
| "LDAP_BIND_PASSWORD_FILE" | ||
| ); | ||
| const baseDN = process.env.LDAP_BASE_DN; | ||
| const userAttribute = process.env.LDAP_USER_ATTRIBUTE || "uid"; | ||
|
|
||
| if (!url || !bindDN || !bindPassword || !baseDN) { | ||
| if (process.env.DEBUGGER) { | ||
| console.error("LDAP - Missing required configuration:", { | ||
| url: !!url, | ||
| bindDN: !!bindDN, | ||
| bindPassword: !!bindPassword, | ||
| baseDN: !!baseDN, | ||
| }); | ||
| } | ||
| return { ok: false, kind: "connection_error" }; | ||
| } | ||
|
|
||
| const client = new Client({ url }); | ||
|
|
||
| try { | ||
| // Step 1: bind as service account to gain search access | ||
| try { | ||
| await client.bind(bindDN, bindPassword); | ||
| } catch (err) { | ||
| if (process.env.DEBUGGER) { | ||
| console.error("LDAP - Service account bind failed:", err); | ||
| } | ||
| return { ok: false, kind: "connection_error" }; | ||
| } | ||
|
|
||
| // Step 2: search for the user entry by configured attribute | ||
| let userDN = ""; | ||
| let memberOf: string[] = []; | ||
|
|
||
| try { | ||
| const { searchEntries } = await client.search(baseDN, { | ||
| filter: `(${userAttribute}=${escapeLdapFilter(username)})`, | ||
| attributes: ["dn", "memberOf"], | ||
| }); | ||
|
|
||
| if (searchEntries.length === 0) { | ||
| return { ok: false, kind: "invalid_credentials" }; | ||
| } | ||
|
|
||
| const entry = searchEntries[0]; | ||
| userDN = entry.dn; | ||
|
|
||
| const raw = entry["memberOf"]; | ||
| if (Array.isArray(raw)) { | ||
| memberOf = raw as string[]; | ||
| } else if (typeof raw === "string") { | ||
| memberOf = [raw]; | ||
| } | ||
| } catch (err) { | ||
| if (process.env.DEBUGGER) { | ||
| console.error("LDAP - User search failed:", err); | ||
| } | ||
| return { ok: false, kind: "connection_error" }; | ||
| } | ||
|
|
||
| // Step 3: bind as the found user to verify their password | ||
| try { | ||
| await client.bind(userDN, password); | ||
| } catch (err) { | ||
| return { ok: false, kind: "invalid_credentials" }; | ||
| } | ||
|
|
||
| // Step 4: apply group-based access control | ||
| const adminGroupList = (process.env.LDAP_ADMIN_GROUPS || "") | ||
| .split("|") | ||
| .map((g) => g.trim()) | ||
| .filter(Boolean); | ||
|
|
||
| const userGroupList = (process.env.LDAP_USER_GROUPS || "") | ||
| .split("|") | ||
| .map((g) => g.trim()) | ||
| .filter(Boolean); | ||
|
|
||
| const isAdmin = | ||
| adminGroupList.length > 0 && | ||
| adminGroupList.some((g) => memberOf.includes(g)); | ||
|
|
||
| if (userGroupList.length > 0) { | ||
| const isInUserGroup = userGroupList.some((g) => memberOf.includes(g)); | ||
| if (!isInUserGroup && !isAdmin) { | ||
| if (process.env.DEBUGGER) { | ||
| console.log("LDAP - User not in allowed groups:", { | ||
| username, | ||
| requiredGroups: process.env.LDAP_USER_GROUPS, | ||
| memberOf, | ||
| }); | ||
| } | ||
| return { ok: false, kind: "unauthorized" }; | ||
| } | ||
| } | ||
|
|
||
| if (process.env.DEBUGGER) { | ||
| console.log("LDAP - Login successful:", { username, isAdmin }); | ||
| } | ||
|
|
||
| return { ok: true, username, isAdmin }; | ||
| } finally { | ||
| try { | ||
| await client.unbind(); | ||
| } catch {} | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I renamed this for clarity