Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions app/(loggedOutRoutes)/auth/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Copy Markdown
Author

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

const allowLocal = isEnvEnabled(process.env.SSO_FALLBACK_LOCAL);

const hasExistingUsers = await hasUsers();
if ((!hasExistingUsers && !ssoEnabled) || (!hasExistingUsers && allowLocal)) {
if (!hasExistingUsers && (!process.env.SSO_MODE || allowLocal)) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here means "any SSO_MODE" (including LDAP). I don't know if you prefer some central variable over this env check.

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>
);
Expand Down
3 changes: 1 addition & 2 deletions app/(loggedOutRoutes)/auth/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import { isEnvEnabled } from "@/app/_utils/env-utils";
export const dynamic = "force-dynamic";

export default async function SetupPage() {
const ssoEnabled = process.env.SSO_MODE === "oidc";
const allowLocal = isEnvEnabled(process.env.SSO_FALLBACK_LOCAL);

if (ssoEnabled && !allowLocal) {
if (process.env.SSO_MODE && !allowLocal) {
redirect("/auth/login");
}

Expand Down
101 changes: 98 additions & 3 deletions app/_server/actions/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,7 +41,7 @@ const hashPassword = (password: string): string => {
};

/**
* 🧙‍♂️
* ‍♂️
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wizard must be put back, dunno why your linter decided it couldn't be rendered. I'll fix it when I pull the PR 😆

You may or may not have noticed a bunch of troll comments around the codebase, I get bored and have to leave stupid stuff around. Try searching the code for @fccview here 😆

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will restore Gandalf!

*/
const _youShallNotPass = (attempts: number): number => {
if (attempts <= 3) return 0;
Expand Down Expand Up @@ -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!
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHECKMATE! 😆

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall I remove the comment?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll sort it out when i finish this up ♥️

let lockReleased = false;

try {
const users = await readJsonFile(USERS_FILE);
Expand Down Expand Up @@ -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) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is only bruteforce protection for local users, because we don't write to the ldap.

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(
Expand Down Expand Up @@ -293,7 +386,9 @@ export const login = async (formData: FormData) => {

redirect("/");
} finally {
await unlock(usersFile);
if (!lockReleased) {
await unlock(usersFile);
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should probably double-check that this change is OK

}
};

Expand Down
133 changes: 133 additions & 0 deletions app/_server/actions/auth/ldap.ts
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 {}
}
}
6 changes: 3 additions & 3 deletions app/_server/actions/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface SessionData {
ipAddress: string;
createdAt: string;
lastActivity: string;
loginType?: "local" | "sso" | "pending-mfa";
loginType?: "local" | "sso" | "ldap" | "pending-mfa";
}

export interface Session {
Expand Down Expand Up @@ -48,7 +48,7 @@ export const readSessions = async (): Promise<Session> => {
export const createSession = async (
sessionId: string,
username: string,
loginType: "local" | "sso" | "pending-mfa",
loginType: "local" | "sso" | "pending-mfa" | "ldap",
): Promise<void> => {
const headersList = await headers();
const userAgent = headersList.get("user-agent") || "Unknown";
Expand Down Expand Up @@ -111,7 +111,7 @@ export const getSessionId = async (): Promise<string> => {
};

export const getLoginType = async (): Promise<
"local" | "sso" | "pending-mfa" | undefined
"local" | "sso" | "pending-mfa" | "ldap" | undefined
> => {
const sessionId = await getSessionId();
if (!sessionId) return undefined;
Expand Down
Loading
Loading