diff --git a/README.md b/README.md index f0c895663..b876a0ac7 100755 --- a/README.md +++ b/README.md @@ -200,6 +200,84 @@ one-time secrets and, most importantly, change the theme colours. ![Screenshot of the theme menu](screenshots/theme.png) +## LDAP Authentication + +CTFNote supports LDAP authentication alongside local authentication, allowing teams to integrate with their existing identity management systems. + +### Enabling LDAP + +To enable LDAP authentication, configure the following environment variables in your `.env` file: + +```bash +# Enable LDAP authentication +LDAP_ENABLED=true + +# LDAP server connection +LDAP_URL=ldap://ldap.forumsys.com:389 +LDAP_BIND_DN=cn=read-only-admin,dc=example,dc=com +LDAP_BIND_PASSWORD=password + +# Search configuration +LDAP_SEARCH_BASE=dc=example,dc=com +LDAP_SEARCH_FILTER=(uid={{username}}) +LDAP_USERNAME_ATTRIBUTE=uid +LDAP_EMAIL_ATTRIBUTE=mail +LDAP_GROUP_ATTRIBUTE=ou + +# Role mapping based on LDAP groups +LDAP_ADMIN_GROUPS=mathematicians +LDAP_MANAGER_GROUPS=scientists +LDAP_USER_GROUPS=chemists +``` + +The example above uses the public LDAP test server from forumsys.com. You can test with users like: +- Username: `einstein` (scientist group → manager role) +- Username: `newton` (scientist group → manager role) +- Username: `curie` (chemist group → member role) +- All test users have password: `password` + +### Features + +#### Authentication Modes + +1. **Both Local and LDAP** (default): + ```bash + LOCAL_AUTH_ENABLED=true + LDAP_ENABLED=true + ``` + Users can choose between local and LDAP login via tabs. + +2. **LDAP Only**: + ```bash + LOCAL_AUTH_ENABLED=false + LDAP_ENABLED=true + ``` + Only LDAP authentication is available. Local registration is disabled. + +3. **Local Only**: + ```bash + LOCAL_AUTH_ENABLED=true + LDAP_ENABLED=false + ``` + Traditional CTFNote authentication only. + +⚠️ **Warning**: Never disable both authentication methods as this will make the instance inaccessible. + +#### Automatic Role Assignment + +Users are automatically assigned CTFNote roles based on their LDAP group membership: +- Users in `LDAP_ADMIN_GROUPS` → Admin role +- Users in `LDAP_MANAGER_GROUPS` → Manager role +- Users in `LDAP_USER_GROUPS` → Member role +- Other users → Guest role + +#### Password Management + +- LDAP users authenticate with their LDAP passwords +- Password changes must be done through your LDAP/Active Directory system +- CTFNote will show a helpful message when LDAP users try to change passwords + + ## Configuration The configuration can be changed in the `.env` file. This file contains diff --git a/api/.env.dev b/api/.env.dev index 842e34454..cc63e472a 100644 --- a/api/.env.dev +++ b/api/.env.dev @@ -1,4 +1,3 @@ - PAD_CREATE_URL=http://localhost:3001/new PAD_SHOW_URL=/ @@ -17,4 +16,21 @@ DISCORD_BOT_TOKEN=secret_token DISCORD_SERVER_ID=server_id DISCORD_VOICE_CHANNELS=3 -WEB_PORT=3000 \ No newline at end of file +WEB_PORT=3000 + +# Authentication Configuration +LOCAL_AUTH_ENABLED=true + +# LDAP Configuration for Development +LDAP_ENABLED=false +LDAP_URL=ldap://ldap.forumsys.com:389 +LDAP_BIND_DN=cn=read-only-admin,dc=example,dc=com +LDAP_BIND_PASSWORD=password +LDAP_SEARCH_BASE=dc=example,dc=com +LDAP_SEARCH_FILTER=(uid={{username}}) +LDAP_USERNAME_ATTRIBUTE=uid +LDAP_EMAIL_ATTRIBUTE=mail +LDAP_GROUP_ATTRIBUTE=ou +LDAP_ADMIN_GROUPS=mathematicians +LDAP_MANAGER_GROUPS=scientists +LDAP_USER_GROUPS=chemists \ No newline at end of file diff --git a/api/.env.ldap b/api/.env.ldap new file mode 100644 index 000000000..64f49709a --- /dev/null +++ b/api/.env.ldap @@ -0,0 +1,25 @@ +# LDAP Configuration for CTFNote +# Set LDAP_ENABLED=true to enable LDAP authentication + +# Basic LDAP Settings +LDAP_ENABLED=true +LDAP_SERVER=ldap.forumsys.com +LDAP_PORT=389 +LDAP_BASE_DN=dc=example,dc=com + +# LDAP Bind Configuration +LDAP_BIND_DN=cn=read-only-admin,dc=example,dc=com +LDAP_BIND_PASSWORD=password + +# User Search Configuration +LDAP_USER_SEARCH_BASE=dc=example,dc=com +LDAP_USER_SEARCH_FILTER=(uid={0}) + +# Role Mapping +LDAP_DEFAULT_ROLE=user_guest +LDAP_MATHEMATICIANS_ROLE=user_member +LDAP_SCIENTISTS_ROLE=user_member + +# Test Users Available: +# Mathematicians: riemann, gauss, euler, euclid (password: password) +# Scientists: einstein, newton, galieleo, tesla (password: password) diff --git a/api/.yarn/cache/@types-asn1-npm-0.2.4-9f3b7d62e3-01fee5a896.zip b/api/.yarn/cache/@types-asn1-npm-0.2.4-9f3b7d62e3-01fee5a896.zip new file mode 100644 index 000000000..53f80131d Binary files /dev/null and b/api/.yarn/cache/@types-asn1-npm-0.2.4-9f3b7d62e3-01fee5a896.zip differ diff --git a/api/.yarn/cache/asn1-npm-0.2.6-bdd07356c4-cf629291fe.zip b/api/.yarn/cache/asn1-npm-0.2.6-bdd07356c4-cf629291fe.zip new file mode 100644 index 000000000..98b52304f Binary files /dev/null and b/api/.yarn/cache/asn1-npm-0.2.6-bdd07356c4-cf629291fe.zip differ diff --git a/api/.yarn/cache/debug-npm-4.4.0-f6efe76023-1847944c2e.zip b/api/.yarn/cache/debug-npm-4.4.0-f6efe76023-1847944c2e.zip new file mode 100644 index 000000000..5bce5f628 Binary files /dev/null and b/api/.yarn/cache/debug-npm-4.4.0-f6efe76023-1847944c2e.zip differ diff --git a/api/.yarn/cache/fsevents-patch-19706e7e35-10.zip b/api/.yarn/cache/fsevents-patch-19706e7e35-10.zip new file mode 100644 index 000000000..aff1ab12c Binary files /dev/null and b/api/.yarn/cache/fsevents-patch-19706e7e35-10.zip differ diff --git a/api/.yarn/cache/ldap-authentication-npm-3.3.4-53187213b7-41be02e41d.zip b/api/.yarn/cache/ldap-authentication-npm-3.3.4-53187213b7-41be02e41d.zip new file mode 100644 index 000000000..bde8d8191 Binary files /dev/null and b/api/.yarn/cache/ldap-authentication-npm-3.3.4-53187213b7-41be02e41d.zip differ diff --git a/api/.yarn/cache/ldapts-npm-7.4.0-c6921df7c0-d36416a4a9.zip b/api/.yarn/cache/ldapts-npm-7.4.0-c6921df7c0-d36416a4a9.zip new file mode 100644 index 000000000..c3c3706cc Binary files /dev/null and b/api/.yarn/cache/ldapts-npm-7.4.0-c6921df7c0-d36416a4a9.zip differ diff --git a/api/.yarn/cache/punycode-npm-2.3.1-97543c420d-febdc4362b.zip b/api/.yarn/cache/punycode-npm-2.3.1-97543c420d-febdc4362b.zip new file mode 100644 index 000000000..399baa675 Binary files /dev/null and b/api/.yarn/cache/punycode-npm-2.3.1-97543c420d-febdc4362b.zip differ diff --git a/api/.yarn/cache/strict-event-emitter-types-npm-2.0.0-f24fda1f61-d7b28708bf.zip b/api/.yarn/cache/strict-event-emitter-types-npm-2.0.0-f24fda1f61-d7b28708bf.zip new file mode 100644 index 000000000..8e495edfb Binary files /dev/null and b/api/.yarn/cache/strict-event-emitter-types-npm-2.0.0-f24fda1f61-d7b28708bf.zip differ diff --git a/api/.yarn/cache/tr46-npm-5.1.1-88f3ca645b-833a0e1044.zip b/api/.yarn/cache/tr46-npm-5.1.1-88f3ca645b-833a0e1044.zip new file mode 100644 index 000000000..8f10f330b Binary files /dev/null and b/api/.yarn/cache/tr46-npm-5.1.1-88f3ca645b-833a0e1044.zip differ diff --git a/api/.yarn/cache/uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip b/api/.yarn/cache/uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip new file mode 100644 index 000000000..6acc3b042 Binary files /dev/null and b/api/.yarn/cache/uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip differ diff --git a/api/.yarn/cache/webidl-conversions-npm-7.0.0-e8c8e30c68-4c4f65472c.zip b/api/.yarn/cache/webidl-conversions-npm-7.0.0-e8c8e30c68-4c4f65472c.zip new file mode 100644 index 000000000..0c5c664fc Binary files /dev/null and b/api/.yarn/cache/webidl-conversions-npm-7.0.0-e8c8e30c68-4c4f65472c.zip differ diff --git a/api/.yarn/cache/whatwg-url-npm-14.2.0-67b670990c-f0a95b0601.zip b/api/.yarn/cache/whatwg-url-npm-14.2.0-67b670990c-f0a95b0601.zip new file mode 100644 index 000000000..1517b674f Binary files /dev/null and b/api/.yarn/cache/whatwg-url-npm-14.2.0-67b670990c-f0a95b0601.zip differ diff --git a/api/migrations/57-ldap-auth.sql b/api/migrations/57-ldap-auth.sql new file mode 100644 index 000000000..b74d51ec8 --- /dev/null +++ b/api/migrations/57-ldap-auth.sql @@ -0,0 +1,69 @@ +-- Add LDAP user authentication function +-- This function will be called from the LDAP plugin to handle user creation/update + +CREATE OR REPLACE FUNCTION ctfnote.login_ldap( + "username" text, + "user_role" ctfnote.role, + "ldap_data" jsonb DEFAULT '{}'::jsonb +) +RETURNS ctfnote.jwt +AS $$ +DECLARE + existing_user ctfnote_private.user; + new_user ctfnote_private.user; +BEGIN + -- Check if user already exists + SELECT * INTO existing_user + FROM ctfnote_private.user + WHERE login = username; + + IF existing_user.id IS NOT NULL THEN + -- User exists, update role if different + IF existing_user.role != user_role THEN + UPDATE ctfnote_private.user + SET role = user_role + WHERE id = existing_user.id; + END IF; + + -- Return token for existing user + RETURN (ctfnote_private.new_token(existing_user.id))::ctfnote.jwt; + ELSE + -- Create new user with LDAP marker in password field + INSERT INTO ctfnote_private.user ("login", "password", "role") + VALUES (username, 'ldap_user', user_role) + RETURNING * INTO new_user; + + -- Create profile + INSERT INTO ctfnote.profile ("id", "username") + VALUES (new_user.id, username); + + -- Return token for new user + RETURN (ctfnote_private.new_token(new_user.id))::ctfnote.jwt; + END IF; +EXCEPTION + WHEN unique_violation THEN + RAISE EXCEPTION 'Username already taken'; +END; +$$ +LANGUAGE plpgsql +STRICT +SECURITY DEFINER; + +-- Grant permission to execute this function to anonymous users (for login) +GRANT EXECUTE ON FUNCTION ctfnote.login_ldap(text, ctfnote.role, jsonb) TO user_anonymous; + +-- Add a function to check if LDAP is enabled (can be used by frontend) +CREATE OR REPLACE FUNCTION ctfnote.ldap_enabled() +RETURNS boolean +AS $$ +BEGIN + -- This will be determined by environment variables in the plugin + -- For now, return false by default + RETURN false; +END; +$$ +LANGUAGE plpgsql +STABLE +SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION ctfnote.ldap_enabled() TO user_anonymous; diff --git a/api/package.json b/api/package.json index 1a9d56390..68fb88712 100644 --- a/api/package.json +++ b/api/package.json @@ -30,6 +30,7 @@ "graphql": "^16.9.0", "graphql-upload-ts": "^2.1.2", "ical-generator": "^7.0.0", + "ldap-authentication": "^3.3.4", "postgraphile": "4.13.0", "postgraphile-plugin-connection-filter": "^2.3.0", "postgres-migrations": "^5.3.0", diff --git a/api/src/config.ts b/api/src/config.ts index c97f8dfad..972d31732 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -14,6 +14,7 @@ export enum DiscordChannelHandleStyle { export type CTFNoteConfig = DeepReadOnly<{ env: string; sessionSecret: string; + localAuthEnabled: boolean; db: { database: string; admin: { @@ -51,6 +52,20 @@ export type CTFNoteConfig = DeepReadOnly<{ registrationRoleId: string; channelHandleStyle: DiscordChannelHandleStyle; }; + ldap: { + enabled: boolean; + url: string; + bindDN: string; + bindPassword: string; + searchBase: string; + searchFilter: string; + usernameAttribute: string; + emailAttribute: string; + groupAttribute: string; + adminGroups: string[]; + managerGroups: string[]; + userGroups: string[]; + }; }>; function getEnv( @@ -70,6 +85,7 @@ function getEnvInt(name: string): number { const config: CTFNoteConfig = { env: getEnv("NODE_ENV"), sessionSecret: getEnv("SESSION_SECRET", ""), + localAuthEnabled: getEnv("LOCAL_AUTH_ENABLED", "true") === "true", db: { database: getEnv("DB_DATABASE"), user: { @@ -112,6 +128,26 @@ const config: CTFNoteConfig = { "agile" ) as DiscordChannelHandleStyle, }, + ldap: { + enabled: getEnv("LDAP_ENABLED", "false") === "true", + url: getEnv("LDAP_URL", "ldap://ldap.forumsys.com:389"), + bindDN: getEnv("LDAP_BIND_DN", "cn=read-only-admin,dc=example,dc=com"), + bindPassword: getEnv("LDAP_BIND_PASSWORD", "password"), + searchBase: getEnv("LDAP_SEARCH_BASE", "dc=example,dc=com"), + searchFilter: getEnv("LDAP_SEARCH_FILTER", "(uid={{username}})"), + usernameAttribute: getEnv("LDAP_USERNAME_ATTRIBUTE", "uid"), + emailAttribute: getEnv("LDAP_EMAIL_ATTRIBUTE", "mail"), + groupAttribute: getEnv("LDAP_GROUP_ATTRIBUTE", "memberOf"), + adminGroups: getEnv("LDAP_ADMIN_GROUPS", "").trim() + ? [getEnv("LDAP_ADMIN_GROUPS", "")] + : [], + managerGroups: getEnv("LDAP_MANAGER_GROUPS", "").trim() + ? [getEnv("LDAP_MANAGER_GROUPS", "")] + : [], + userGroups: getEnv("LDAP_USER_GROUPS", "").trim() + ? [getEnv("LDAP_USER_GROUPS", "")] + : [], + }, }; export default config; diff --git a/api/src/index.ts b/api/src/index.ts index 483e4e3fc..484da5b0e 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -14,6 +14,8 @@ import createTasKPlugin from "./plugins/createTask"; import importCtfPlugin from "./plugins/importCtf"; import uploadLogoPlugin from "./plugins/uploadLogo"; import uploadScalar from "./plugins/uploadScalar"; +import ldapAuthPlugin from "./plugins/ldapAuth"; +import localAuthControlPlugin from "./plugins/localAuthControl"; import { Pool } from "pg"; import { icalRoute } from "./routes/ical"; import ConnectionFilterPlugin from "postgraphile-plugin-connection-filter"; @@ -63,6 +65,8 @@ function createOptions() { discordHooks, PgManyToManyPlugin, ProfileSubscriptionPlugin, + ldapAuthPlugin, + ...localAuthControlPlugin, ], ownerConnectionString: getDbUrl("admin"), enableQueryBatching: true, @@ -143,12 +147,76 @@ async function performMigrations() { await migrate(dbConfig, "./migrations"); } +function validateAuthConfiguration() { + // Check if at least one authentication method is enabled + if (!config.localAuthEnabled && !config.ldap.enabled) { + console.error( + "┌──────────────────────────────────────────────────────────────────┐" + ); + console.error( + "│ ⚠️ CRITICAL WARNING ⚠️ │" + ); + console.error( + "├──────────────────────────────────────────────────────────────────┤" + ); + console.error( + "│ Both LOCAL_AUTH_ENABLED and LDAP_ENABLED are set to false! │" + ); + console.error( + "│ This instance is misconfigured and users cannot authenticate. │" + ); + console.error( + "│ │" + ); + console.error( + "│ Please enable at least one authentication method: │" + ); + console.error( + "│ - Set LOCAL_AUTH_ENABLED=true for local authentication │" + ); + console.error( + "│ - Set LDAP_ENABLED=true for LDAP authentication │" + ); + console.error( + "│ │" + ); + console.error( + "│ The server will continue running but authentication will fail. │" + ); + console.error( + "└──────────────────────────────────────────────────────────────────┘" + ); + + // In production, we should consider exiting + if (config.env === "production") { + console.error( + "\n❌ Exiting due to misconfiguration in production environment." + ); + process.exit(1); + } + } else if (!config.localAuthEnabled && config.ldap.enabled) { + console.info( + "ℹ️ Local authentication is disabled. Only LDAP authentication is available." + ); + } else if (config.localAuthEnabled && !config.ldap.enabled) { + console.info( + "ℹ️ LDAP authentication is disabled. Only local authentication is available." + ); + } else { + console.info("✅ Both local and LDAP authentication methods are enabled."); + } +} + async function main() { await performMigrations(); if (config.db.migrateOnly) { console.log("Migrations done. Exiting."); return; } + + // Validate authentication configuration before starting the server + validateAuthConfiguration(); + const postgraphileOptions = createOptions(); const app = createApp(postgraphileOptions); diff --git a/api/src/plugins/ldapAuth.ts b/api/src/plugins/ldapAuth.ts new file mode 100644 index 000000000..707e3b4fd --- /dev/null +++ b/api/src/plugins/ldapAuth.ts @@ -0,0 +1,326 @@ +import { makeExtendSchemaPlugin, gql } from "graphile-utils"; +import * as ldapAuthentication from "ldap-authentication"; +import * as jwt from "jsonwebtoken"; +import config from "../config"; +import { Context } from "./uploadLogo"; + +interface LdapUserInfo { + uid: string; + cn: string; + mail?: string; + memberOf?: string[]; +} + +// Rate limiting for LDAP authentication attempts +const authAttempts = new Map(); +const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes +const MAX_ATTEMPTS = 5; + +function checkRateLimit(username: string): boolean { + const now = Date.now(); + const userAttempts = authAttempts.get(username); + + if (!userAttempts) { + authAttempts.set(username, { count: 1, lastAttempt: now }); + return true; + } + + // Reset if outside window + if (now - userAttempts.lastAttempt > RATE_LIMIT_WINDOW) { + authAttempts.set(username, { count: 1, lastAttempt: now }); + return true; + } + + // Check if within limits + if (userAttempts.count >= MAX_ATTEMPTS) { + return false; + } + + // Increment count + userAttempts.count++; + userAttempts.lastAttempt = now; + return true; +} + +// Clean up old entries periodically +setInterval(() => { + const now = Date.now(); + authAttempts.forEach((attempts, username) => { + if (now - attempts.lastAttempt > RATE_LIMIT_WINDOW) { + authAttempts.delete(username); + } + }); +}, RATE_LIMIT_WINDOW); + +// Validate username to prevent LDAP injection +function validateLdapUsername(username: string): boolean { + // Allow alphanumeric, dots, hyphens, underscores + const validUsernameRegex = /^[a-zA-Z0-9._-]+$/; + return validUsernameRegex.test(username) && username.length <= 64; +} + +// Check if user is member of specific groups by querying the groups directly +async function getUserGroupsFromLdap(userDn: string): Promise { + const groups: string[] = []; + + // Combine all configured groups to check + const allGroups = [ + ...config.ldap.adminGroups, + ...config.ldap.managerGroups, + ...config.ldap.userGroups, + ]; + + for (const groupName of allGroups) { + try { + // Try to authenticate against the group to see if user is a member + // This is a workaround since ldap-authentication doesn't support direct group queries + const groupDn = `ou=${groupName},${config.ldap.searchBase}`; + + // Use a simple LDAP bind test to check if the group exists and contains the user + // Note: This is a simplified approach and may need adjustment based on your LDAP schema + const testOptions = { + ldapOpts: { + url: config.ldap.url, + connectTimeout: 5000, + timeout: 10000, + }, + adminDn: config.ldap.bindDN, + adminPassword: config.ldap.bindPassword, + userSearchBase: groupDn, + usernameAttribute: "uniqueMember", + username: userDn, + userPassword: "dummy", // Not used for this type of search + verifyUserExists: true, + }; + + const result = await ldapAuthentication.authenticate(testOptions); + if (result) { + groups.push(groupName); + } + } catch (error) { + // Group doesn't exist or user is not a member - ignore + continue; + } + } + + return groups; +} + +async function authenticateWithLdap( + username: string, + password: string +): Promise { + if (!config.ldap.enabled) { + throw new Error("LDAP authentication is not enabled"); + } + + // Validate username to prevent injection + if (!validateLdapUsername(username)) { + console.warn("Invalid LDAP username format attempted"); + return null; + } + + // Log authentication attempt without sensitive data + console.log("LDAP authentication attempt"); + + try { + const options = { + ldapOpts: { + url: config.ldap.url, + connectTimeout: 10000, // 10 second connection timeout + timeout: 30000, // 30 second operation timeout + }, + adminDn: config.ldap.bindDN, + adminPassword: config.ldap.bindPassword, + userSearchBase: config.ldap.searchBase, + usernameAttribute: config.ldap.usernameAttribute, + username: username, + userPassword: password, + }; + + const user = await ldapAuthentication.authenticate(options); + + if (user) { + console.log("LDAP authentication successful"); + + // For LDAP servers that use reverse group membership (like forumsys.com) + // we need to search for groups that contain this user as a member + let groups: string[] = []; + + if ( + user[config.ldap.groupAttribute] && + user[config.ldap.groupAttribute].length > 0 + ) { + // Standard memberOf attribute exists + groups = user[config.ldap.groupAttribute]; + } else { + // For LDAP servers that don't provide memberOf attribute, + // query each configured group to see if user is a member + console.log( + "No memberOf attribute found, attempting group lookup via configured groups" + ); + try { + groups = await getUserGroupsFromLdap(user.dn); + console.log("Found groups via direct group query:", groups); + } catch (groupError) { + console.warn("Failed to lookup user groups:", groupError); + console.log( + "To enable role assignment, ensure LDAP_ADMIN_GROUPS, LDAP_MANAGER_GROUPS, and LDAP_USER_GROUPS are configured" + ); + groups = []; + } + } + + return { + uid: user[config.ldap.usernameAttribute] || username, + cn: user.cn || user.displayName || username, + mail: user[config.ldap.emailAttribute], + memberOf: groups, + }; + } + + // Authentication failed - no logging to prevent user enumeration + return null; + } catch (error) { + // Log error without sensitive details + console.error("LDAP authentication failed"); + return null; + } +} + +function getUserRoleFromGroups(memberOf: string[]): string { + // For FreeIPA, we need to do exact DN matching since the groups are returned as full DNs + // like "cn=ctfnote-admins,cn=groups,cn=accounts,dc=ctfnote,dc=local" + + // Check admin groups first (exact DN match) + for (const configuredGroup of config.ldap.adminGroups) { + if (memberOf.includes(configuredGroup)) { + return "user_admin"; + } + } + + // Check manager groups (exact DN match) + for (const configuredGroup of config.ldap.managerGroups) { + if (memberOf.includes(configuredGroup)) { + return "user_manager"; + } + } + + // Check user groups (exact DN match) + for (const configuredGroup of config.ldap.userGroups) { + if (memberOf.includes(configuredGroup)) { + return "user_member"; + } + } + + // Default role + return "user_guest"; +} + +export default makeExtendSchemaPlugin(() => { + return { + typeDefs: gql` + extend type Query { + ldapAuthEnabled: Boolean + } + ${config.ldap.enabled + ? ` + type LdapAuthPayload { + jwt: String + } + extend type Mutation { + authenticateWithLdap(username: String!, password: String!): LdapAuthPayload + } + ` + : ""} + `, + resolvers: { + Query: { + ldapAuthEnabled: () => config.ldap.enabled, + }, + ...(config.ldap.enabled + ? { + Mutation: { + authenticateWithLdap: async ( + _parent: unknown, + args: { username: string; password: string }, + context: Context + ) => { + // Process LDAP authentication request + const { username, password } = args; + + if (!config.ldap.enabled) { + throw new Error("LDAP authentication is not enabled"); + } + + // Check rate limit + if (!checkRateLimit(username)) { + throw new Error( + "Too many authentication attempts. Please try again later." + ); + } + + // Authenticate with LDAP + const ldapUser = await authenticateWithLdap(username, password); + + if (!ldapUser) { + throw new Error("Invalid LDAP credentials"); + } + + // Determine user role based on LDAP groups + const userRole = getUserRoleFromGroups(ldapUser.memberOf || []); + // User role determined from groups + + try { + // Use the database function to handle user creation/update + // Call database function to handle user creation/update + const result = await context.pgClient.query( + `SELECT (ctfnote.login_ldap($1, $2, $3)).*`, + [username, userRole, JSON.stringify(ldapUser)] + ); + + // Process database result + + const jwtRow = result.rows[0]; + // Validate JWT row + + if (!jwtRow || !jwtRow.user_id) { + // Generic error without revealing internals + throw new Error("Authentication failed"); + } + + // Create JWT payload from the database result + const jwtPayload = { + user_id: parseInt(jwtRow.user_id), + role: jwtRow.role, + exp: parseInt(jwtRow.exp), + aud: "postgraphile", // Add the required audience + iss: "ctfnote-ldap", // Add issuer claim + iat: Math.floor(Date.now() / 1000), // Add issued at timestamp + }; + // JWT payload created + + // Sign the JWT token using the same secret as PostGraphile + const jwtSecret = + config.env === "development" ? "DEV" : config.sessionSecret; + // Explicitly specify algorithm to prevent algorithm confusion attacks + const signedJwt = jwt.sign(jwtPayload, jwtSecret, { + algorithm: "HS256", + }); + // JWT token signed + + return { + jwt: signedJwt, + }; + } catch (dbError) { + // Log sanitized error + console.error("LDAP login processing failed"); + throw new Error("Authentication failed"); + } + }, + }, + } + : {}), + }, + }; +}); diff --git a/api/src/plugins/localAuthControl.ts b/api/src/plugins/localAuthControl.ts new file mode 100644 index 000000000..85e2432e3 --- /dev/null +++ b/api/src/plugins/localAuthControl.ts @@ -0,0 +1,62 @@ +import { + makeExtendSchemaPlugin, + gql, + makeWrapResolversPlugin, +} from "graphile-utils"; +import config from "../config"; + +// Export a function that returns a plugin array +export default [ + // Add the localAuthEnabled query + makeExtendSchemaPlugin(() => ({ + typeDefs: gql` + extend type Query { + localAuthEnabled: Boolean + } + `, + resolvers: { + Query: { + localAuthEnabled: () => config.localAuthEnabled, + }, + }, + })), + + // Wrap the existing mutations to check if local auth is enabled + makeWrapResolversPlugin({ + Mutation: { + login: { + requires: {}, + resolve: async (resolver, source, args, context, resolveInfo) => { + if (!config.localAuthEnabled) { + throw new Error( + "Local authentication is disabled. Please use LDAP authentication." + ); + } + return resolver(source, args, context, resolveInfo); + }, + }, + register: { + requires: {}, + resolve: async (resolver, source, args, context, resolveInfo) => { + if (!config.localAuthEnabled) { + throw new Error( + "Local authentication is disabled. Registration is not available." + ); + } + return resolver(source, args, context, resolveInfo); + }, + }, + registerWithPassword: { + requires: {}, + resolve: async (resolver, source, args, context, resolveInfo) => { + if (!config.localAuthEnabled) { + throw new Error( + "Local authentication is disabled. Registration is not available." + ); + } + return resolver(source, args, context, resolveInfo); + }, + }, + }, + }), +]; diff --git a/api/yarn.lock b/api/yarn.lock index 01811db16..0ef98193a 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -370,6 +370,15 @@ __metadata: languageName: node linkType: hard +"@types/asn1@npm:>=0.2.4": + version: 0.2.4 + resolution: "@types/asn1@npm:0.2.4" + dependencies: + "@types/node": "npm:*" + checksum: 10/01fee5a896a650f92c95556c83678d66677060b5aba7936b714b5c8ca2945cc8cabf57441e323f1800a783a55958b6230d109e988ca45e83b2d7a0e071c24069 + languageName: node + linkType: hard + "@types/body-parser@npm:*": version: 1.19.2 resolution: "@types/body-parser@npm:1.19.2" @@ -918,6 +927,15 @@ __metadata: languageName: node linkType: hard +"asn1@npm:0.2.6": + version: 0.2.6 + resolution: "asn1@npm:0.2.6" + dependencies: + safer-buffer: "npm:~2.1.0" + checksum: 10/cf629291fee6c1a6f530549939433ebf32200d7849f38b810ff26ee74235e845c0c12b2ed0f1607ac17383d19b219b69cefa009b920dab57924c5c544e495078 + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -1308,6 +1326,7 @@ __metadata: graphql: "npm:^16.9.0" graphql-upload-ts: "npm:^2.1.2" ical-generator: "npm:^7.0.0" + ldap-authentication: "npm:^3.3.4" lint-staged: "npm:^15.2.2" nodemon: "npm:^3.1.7" postgraphile: "npm:4.13.0" @@ -1341,6 +1360,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:4.4.0": + version: 4.4.0 + resolution: "debug@npm:4.4.0" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10/1847944c2e3c2c732514b93d11886575625686056cd765336212dc15de2d2b29612b6cd80e1afba767bb8e1803b778caf9973e98169ef1a24a7a7009e1820367 + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -2679,6 +2710,29 @@ __metadata: languageName: node linkType: hard +"ldap-authentication@npm:^3.3.4": + version: 3.3.4 + resolution: "ldap-authentication@npm:3.3.4" + dependencies: + ldapts: "npm:^7.3.1" + checksum: 10/41be02e41d815275da2f861efb349dccea3f5e3f76d95d08c71929b00301656a56f63b3acbca5cfa959ab7e0997e56ef53166c77cbf49c51bf22e0a4f93318ca + languageName: node + linkType: hard + +"ldapts@npm:^7.3.1": + version: 7.4.0 + resolution: "ldapts@npm:7.4.0" + dependencies: + "@types/asn1": "npm:>=0.2.4" + asn1: "npm:0.2.6" + debug: "npm:4.4.0" + strict-event-emitter-types: "npm:2.0.0" + uuid: "npm:11.1.0" + whatwg-url: "npm:14.2.0" + checksum: 10/d36416a4a912f75df02ee1a3c75095396008c79fae5e437f104d94c32ff5601382a4bdf9c1301707fab4a425baf70e23f0898f7363c03319289f33ec917184ff + languageName: node + linkType: hard + "levn@npm:^0.4.1": version: 0.4.1 resolution: "levn@npm:0.4.1" @@ -3070,7 +3124,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:2.1.3, ms@npm:^2.0.0, ms@npm:^2.1.1": +"ms@npm:2.1.3, ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: 10/aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d @@ -3623,6 +3677,13 @@ __metadata: languageName: node linkType: hard +"punycode@npm:^2.3.1": + version: 2.3.1 + resolution: "punycode@npm:2.3.1" + checksum: 10/febdc4362bead22f9e2608ff0171713230b57aff9dddc1c273aa2a651fbd366f94b7d6a71d78342a7c0819906750351ca7f2edd26ea41b626d87d6a13d1bd059 + languageName: node + linkType: hard + "qs@npm:6.13.0": version: 6.13.0 resolution: "qs@npm:6.13.0" @@ -3743,7 +3804,7 @@ __metadata: languageName: node linkType: hard -"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0": +"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:~2.1.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" checksum: 10/7eaf7a0cf37cc27b42fb3ef6a9b1df6e93a1c6d98c6c6702b02fe262d5fcbd89db63320793b99b21cb5348097d0a53de81bd5f4e8b86e20cc9412e3f1cfb4e83 @@ -4019,6 +4080,13 @@ __metadata: languageName: node linkType: hard +"strict-event-emitter-types@npm:2.0.0": + version: 2.0.0 + resolution: "strict-event-emitter-types@npm:2.0.0" + checksum: 10/d7b28708bf09648302e8ea5a6c4999d7e3186a06f68568a6196a71e315bf4ef44f78011db11576b0b4ed45df4a8f0baf0d64232d2a6eaf21fa92c7ec4085f481 + languageName: node + linkType: hard + "string-argv@npm:0.3.2": version: 0.3.2 resolution: "string-argv@npm:0.3.2" @@ -4188,6 +4256,15 @@ __metadata: languageName: node linkType: hard +"tr46@npm:^5.1.0": + version: 5.1.1 + resolution: "tr46@npm:5.1.1" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10/833a0e1044574da5790148fd17866d4ddaea89e022de50279967bcd6b28b4ce0d30d59eb3acf9702b60918975b3bad481400337e3a2e6326cffa5c77b874753d + languageName: node + linkType: hard + "ts-api-utils@npm:^1.0.1, ts-api-utils@npm:^1.3.0": version: 1.3.0 resolution: "ts-api-utils@npm:1.3.0" @@ -4378,6 +4455,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:11.1.0": + version: 11.1.0 + resolution: "uuid@npm:11.1.0" + bin: + uuid: dist/esm/bin/uuid + checksum: 10/d2da43b49b154d154574891ced66d0c83fc70caaad87e043400cf644423b067542d6f3eb641b7c819224a7cd3b4c2f21906acbedd6ec9c6a05887aa9115a9cf5 + languageName: node + linkType: hard + "v8-compile-cache-lib@npm:^3.0.1": version: 3.0.1 resolution: "v8-compile-cache-lib@npm:3.0.1" @@ -4392,6 +4478,23 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10/4c4f65472c010eddbe648c11b977d048dd96956a625f7f8b9d64e1b30c3c1f23ea1acfd654648426ce5c743c2108a5a757c0592f02902cf7367adb7d14e67721 + languageName: node + linkType: hard + +"whatwg-url@npm:14.2.0": + version: 14.2.0 + resolution: "whatwg-url@npm:14.2.0" + dependencies: + tr46: "npm:^5.1.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10/f0a95b0601c64f417c471536a2d828b4c16fe37c13662483a32f02f183ed0f441616609b0663fb791e524e8cd56d9a86dd7366b1fc5356048ccb09b576495e7c + languageName: node + linkType: hard + "which@npm:^2.0.1, which@npm:^2.0.2": version: 2.0.2 resolution: "which@npm:2.0.2" diff --git a/docker-compose.yml b/docker-compose.yml index 0624509f6..c6d562ee5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,19 @@ services: TZ: ${TZ:-UTC} LC_ALL: ${LC_ALL:-en_US.UTF-8} SESSION_SECRET: ${SESSION_SECRET:-} + # LDAP Configuration + LDAP_ENABLED: ${LDAP_ENABLED:-false} + LDAP_URL: ${LDAP_URL:-} + LDAP_BIND_DN: ${LDAP_BIND_DN:-} + LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-} + LDAP_SEARCH_BASE: ${LDAP_SEARCH_BASE:-} + LDAP_SEARCH_FILTER: ${LDAP_SEARCH_FILTER:-} + LDAP_USERNAME_ATTRIBUTE: ${LDAP_USERNAME_ATTRIBUTE:-uid} + LDAP_EMAIL_ATTRIBUTE: ${LDAP_EMAIL_ATTRIBUTE:-mail} + LDAP_GROUP_ATTRIBUTE: ${LDAP_GROUP_ATTRIBUTE:-memberOf} + LDAP_ADMIN_GROUPS: ${LDAP_ADMIN_GROUPS:-} + LDAP_MANAGER_GROUPS: ${LDAP_MANAGER_GROUPS:-} + LDAP_USER_GROUPS: ${LDAP_USER_GROUPS:-} depends_on: - db volumes: diff --git a/front/.yarn/cache/fsevents-patch-6b67494872-10.zip b/front/.yarn/cache/fsevents-patch-6b67494872-10.zip new file mode 100644 index 000000000..9887ada72 Binary files /dev/null and b/front/.yarn/cache/fsevents-patch-6b67494872-10.zip differ diff --git a/front/.yarn/cache/sass-embedded-darwin-arm64-npm-1.80.6-abb6e348c4-10.zip b/front/.yarn/cache/sass-embedded-darwin-arm64-npm-1.80.6-abb6e348c4-10.zip new file mode 100644 index 000000000..8206af945 Binary files /dev/null and b/front/.yarn/cache/sass-embedded-darwin-arm64-npm-1.80.6-abb6e348c4-10.zip differ diff --git a/front/.yarn/cache/sass-embedded-linux-musl-x64-npm-1.80.6-99254138dc-10.zip b/front/.yarn/cache/sass-embedded-linux-musl-x64-npm-1.80.6-99254138dc-10.zip deleted file mode 100644 index 55b7f59f1..000000000 Binary files a/front/.yarn/cache/sass-embedded-linux-musl-x64-npm-1.80.6-99254138dc-10.zip and /dev/null differ diff --git a/front/.yarn/cache/sass-embedded-linux-x64-npm-1.80.6-57cea4e1c3-10.zip b/front/.yarn/cache/sass-embedded-linux-x64-npm-1.80.6-57cea4e1c3-10.zip deleted file mode 100644 index 5c5749f7d..000000000 Binary files a/front/.yarn/cache/sass-embedded-linux-x64-npm-1.80.6-57cea4e1c3-10.zip and /dev/null differ diff --git a/front/graphql.schema.json b/front/graphql.schema.json index 2845f01c5..486f6c13c 100644 --- a/front/graphql.schema.json +++ b/front/graphql.schema.json @@ -6010,6 +6010,30 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "LdapAuthPayload", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "jwt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "ListenPayload", @@ -6114,6 +6138,122 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "LoginLdapInput", + "description": "All input for the `loginLdap` mutation.", + "isOneOf": false, + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "An arbitrary string value with no semantic meaning. Will be included in the\npayload verbatim. May be used to track mutations by the client.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ldapData", + "description": null, + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userRole", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "Role", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "username", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "LoginLdapPayload", + "description": "The output of our `loginLdap` mutation.", + "isOneOf": null, + "fields": [ + { + "name": "clientMutationId", + "description": "The exact same `clientMutationId` that was provided in the mutation input,\nunchanged and unused. May be used by a client to track mutations.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "jwt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Jwt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "query", + "description": "Our root query field type. Allows us to run any query from our mutation payload.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "Query", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "LoginPayload", @@ -6226,6 +6366,51 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "authenticateWithLdap", + "description": null, + "args": [ + { + "name": "password", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "username", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LdapAuthPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "cancelWorkingOn", "description": null, @@ -6885,6 +7070,35 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "loginLdap", + "description": null, + "args": [ + { + "name": "input", + "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "LoginLdapInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LoginLdapPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "register", "description": null, @@ -10278,6 +10492,42 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "ldapAuthEnabled", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ldapEnabled", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "localAuthEnabled", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "me", "description": null, diff --git a/front/src/components/Auth/Login.vue b/front/src/components/Auth/Login.vue index 1c990aa07..5a354130c 100644 --- a/front/src/components/Auth/Login.vue +++ b/front/src/components/Auth/Login.vue @@ -1,11 +1,43 @@