Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions api/.env.dev
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

PAD_CREATE_URL=http://localhost:3001/new
PAD_SHOW_URL=/

Expand All @@ -17,4 +16,21 @@ DISCORD_BOT_TOKEN=secret_token
DISCORD_SERVER_ID=server_id
DISCORD_VOICE_CHANNELS=3

WEB_PORT=3000
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
Copy link
Contributor

Choose a reason for hiding this comment

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

You should either use singular or support specifying multiple groups

25 changes: 25 additions & 0 deletions api/.env.ldap
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

This file seems to be for an earlier version of this PR and seems wrong for the current version


# Test Users Available:
# Mathematicians: riemann, gauss, euler, euclid (password: password)
# Scientists: einstein, newton, galieleo, tesla (password: password)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added api/.yarn/cache/fsevents-patch-19706e7e35-10.zip
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
69 changes: 69 additions & 0 deletions api/migrations/57-ldap-auth.sql
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Contributor

Choose a reason for hiding this comment

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

This must be a private function, because if it is public, I can use it to request a JWT for arbitrary (non-existing) users with arbitrary roles:

[{"operationName":"LoginLdap","variables":{"username":"test","userRole":"USER_ADMIN"},"query":"mutation LoginLdap($username: String!, $userRole: Role!) {\n  loginLdap(input: {username: $username, userRole: $userRole}) {\n    jwt\n    __typename\n  }\n}"}]

"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;
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum DiscordChannelHandleStyle {
export type CTFNoteConfig = DeepReadOnly<{
env: string;
sessionSecret: string;
localAuthEnabled: boolean;
db: {
database: string;
admin: {
Expand Down Expand Up @@ -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(
Expand All @@ -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: {
Expand Down Expand Up @@ -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;
68 changes: 68 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -63,6 +65,8 @@ function createOptions() {
discordHooks,
PgManyToManyPlugin,
ProfileSubscriptionPlugin,
ldapAuthPlugin,
...localAuthControlPlugin,
],
ownerConnectionString: getDbUrl("admin"),
enableQueryBatching: true,
Expand Down Expand Up @@ -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);

Expand Down
Loading