feat: add ldap and oidc sso (#500)

* wip: sso

* feat: add ldap client and provider

* feat: implement login form

* feat: finish sso

* fix: lint and format issue

* chore: address pull request feedback

* fix: build not working

* fix: oidc is redirected to internal docker container hostname

* fix: build not working

* refactor: migrate to ldapts

* fix: format and frozen lock file

* fix: deepsource issues

* fix: unit tests for ldap authorization not working

* refactor: remove unnecessary args from dockerfile

* chore: address pull request feedback

* fix: use console instead of logger in auth env.mjs

* fix: default value for auth provider of wrong type

* fix: broken lock file

* fix: format issue
This commit is contained in:
Meier Lukas
2024-07-20 22:23:58 +02:00
committed by GitHub
parent 5da74ca7e0
commit dc75ffb9e6
27 changed files with 1112 additions and 189 deletions

View File

@@ -0,0 +1,36 @@
import bcrypt from "bcrypt";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { users } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import type { validation, z } from "@homarr/validation";
export const authorizeWithBasicCredentialsAsync = async (
db: Database,
credentials: z.infer<typeof validation.user.signIn>,
) => {
const user = await db.query.users.findFirst({
where: eq(users.name, credentials.name),
});
if (!user?.password) {
logger.info(`user ${credentials.name} was not found`);
return null;
}
logger.info(`user ${user.name} is trying to log in. checking password...`);
const isValidPassword = await bcrypt.compare(credentials.password, user.password);
if (!isValidPassword) {
logger.warn(`password for user ${user.name} was incorrect`);
return null;
}
logger.info(`user ${user.name} successfully authorized`);
return {
id: user.id,
name: user.name,
};
};

View File

@@ -0,0 +1,134 @@
import type { Adapter } from "@auth/core/adapters";
import { CredentialsSignin } from "@auth/core/errors";
import { createId } from "@homarr/db";
import { logger } from "@homarr/log";
import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";
import { env } from "../../../env.mjs";
import { LdapClient } from "../ldap-client";
export const authorizeWithLdapCredentialsAsync = async (
adapter: Adapter,
credentials: z.infer<typeof validation.user.signIn>,
) => {
logger.info(`user ${credentials.name} is trying to log in using LDAP. Connecting to LDAP server...`);
const client = new LdapClient();
await client
.bindAsync({
distinguishedName: env.AUTH_LDAP_BIND_DN,
password: env.AUTH_LDAP_BIND_PASSWORD,
})
.catch(() => {
logger.error("Failed to connect to LDAP server");
throw new CredentialsSignin();
});
logger.info("Connected to LDAP server. Searching for user...");
const ldapUser = await client
.searchAsync({
base: env.AUTH_LDAP_BASE,
options: {
filter: createLdapUserFilter(credentials.name),
scope: env.AUTH_LDAP_SEARCH_SCOPE,
attributes: [env.AUTH_LDAP_USERNAME_ATTRIBUTE, env.AUTH_LDAP_USER_MAIL_ATTRIBUTE],
},
})
.then((entries) => entries.at(0));
if (!ldapUser) {
logger.warn(`User ${credentials.name} not found in LDAP`);
throw new CredentialsSignin();
}
// Validate email
const mailResult = await z.string().email().safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]);
if (!mailResult.success) {
logger.error(
`User ${credentials.name} found but with invalid or non-existing Email. Not Supported: "${ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]}"`,
);
throw new CredentialsSignin();
}
logger.info(`User ${credentials.name} found in LDAP. Logging in...`);
// Bind with user credentials to check if the password is correct
const userClient = new LdapClient();
await userClient
.bindAsync({
distinguishedName: ldapUser.dn,
password: credentials.password,
})
.catch(() => {
logger.warn(`Wrong credentials for user ${credentials.name}`);
throw new CredentialsSignin();
});
await userClient.disconnectAsync();
logger.info(`User ${credentials.name} logged in successfully, retrieving user groups...`);
const userGroups = await client
.searchAsync({
base: env.AUTH_LDAP_BASE,
options: {
// For example, if the user is doejohn, the filter will be (&(objectClass=group)(uid=doejohn)) or (&(objectClass=group)(uid=doejohn)(sAMAccountType=1234))
filter: `(&(objectClass=${env.AUTH_LDAP_GROUP_CLASS})(${
env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE
}=${ldapUser[env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE]})${env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG ?? ""})`,
scope: env.AUTH_LDAP_SEARCH_SCOPE,
attributes: ["cn"],
},
})
.then((entries) => entries.map((entry) => entry.cn).filter((group): group is string => group !== undefined));
logger.info(`Found ${userGroups.length} groups for user ${credentials.name}.`);
await client.disconnectAsync();
// Create or update user in the database
let user = await adapter.getUserByEmail?.(mailResult.data);
if (!user) {
logger.info(`User ${credentials.name} not found in the database. Creating...`);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user = await adapter.createUser!({
id: createId(),
name: credentials.name,
email: mailResult.data,
emailVerified: new Date(), // assume email is verified
});
logger.info(`User ${credentials.name} created successfully.`);
}
if (user.name !== credentials.name) {
logger.warn(`User ${credentials.name} found in the database but with different name. Updating...`);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user = await adapter.updateUser!({
id: user.id,
name: credentials.name,
});
logger.info(`User ${credentials.name} updated successfully.`);
}
return {
id: user.id,
name: user.name,
};
};
const createLdapUserFilter = (username: string) => {
if (env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG) {
// For example, if the username is doejohn and the extra arg is (sAMAccountType=1234), the filter will be (&(uid=doejohn)(sAMAccountType=1234))
return `(&(${env.AUTH_LDAP_USERNAME_ATTRIBUTE}=${username})${env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG})`;
}
// For example, if the username is doejohn, the filter will be (uid=doejohn)
return `(${env.AUTH_LDAP_USERNAME_ATTRIBUTE}=${username})`;
};

View File

@@ -0,0 +1,40 @@
import type Credentials from "@auth/core/providers/credentials";
import type { Database } from "@homarr/db";
import { validation } from "@homarr/validation";
import { adapter } from "../../adapter";
import { authorizeWithBasicCredentialsAsync } from "./authorization/basic-authorization";
import { authorizeWithLdapCredentialsAsync } from "./authorization/ldap-authorization";
type CredentialsConfiguration = Parameters<typeof Credentials>[0];
export const createCredentialsConfiguration = (db: Database) =>
({
type: "credentials",
name: "Credentials",
credentials: {
name: {
label: "Username",
type: "text",
},
password: {
label: "Password",
type: "password",
},
isLdap: {
label: "LDAP",
type: "checkbox",
},
},
// eslint-disable-next-line no-restricted-syntax
async authorize(credentials) {
const data = await validation.user.signIn.parseAsync(credentials);
if (data.credentialType === "ldap") {
return await authorizeWithLdapCredentialsAsync(adapter, data).catch(() => null);
}
return await authorizeWithBasicCredentialsAsync(db, data);
},
}) satisfies CredentialsConfiguration;

View File

@@ -0,0 +1,89 @@
import type { Entry, SearchOptions as LdapSearchOptions } from "ldapts";
import { Client } from "ldapts";
import { objectEntries } from "@homarr/common";
import { env } from "../../env.mjs";
export interface BindOptions {
distinguishedName: string;
password: string;
}
interface SearchOptions {
base: string;
options: LdapSearchOptions;
}
export class LdapClient {
private client: Client;
constructor() {
this.client = new Client({
url: env.AUTH_LDAP_URI,
});
}
/**
* Binds to the LDAP server with the provided distinguishedName and password.
* @param distinguishedName distinguishedName to bind to
* @param password password to bind with
* @returns void
*/
public async bindAsync({ distinguishedName, password }: BindOptions) {
return await this.client.bind(distinguishedName, password);
}
/**
* Search for entries in the LDAP server.
* @param base base DN to start the search
* @param options search options
* @returns list of search results
*/
public async searchAsync({ base, options }: SearchOptions) {
const { searchEntries } = await this.client.search(base, options);
return searchEntries.map((entry) => {
return {
...objectEntries(entry)
.map(([key, value]) => [key, LdapClient.convertEntryPropertyToString(value)] as const)
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {} as Record<string, string>),
dn: LdapClient.getEntryDn(entry),
} as {
[key: string]: string;
dn: string;
};
});
}
private static convertEntryPropertyToString(value: Entry[string]) {
const firstValue = Array.isArray(value) ? (value[0] ?? "") : value;
if (firstValue instanceof Buffer) {
return firstValue.toString("utf8");
}
return firstValue;
}
/**
* dn is the only attribute returned with special characters formatted in UTF-8 (Bad for any letters with an accent)
* Regex replaces any backslash followed by 2 hex characters with a percentage unless said backslash is preceded by another backslash.
* That can then be processed by decodeURIComponent which will turn back characters to normal.
* @param entry search entry from ldap
* @returns normalized distinguishedName
*/
private static getEntryDn(entry: Entry) {
try {
return decodeURIComponent(entry.dn.replace(/(?<!\\)\\([0-9a-fA-F]{2})/g, "%$1"));
} catch {
throw new Error(`Cannot resolve distinguishedName for the entry ${entry.dn}`);
}
}
/**
* Disconnects the client from the LDAP server.
*/
public async disconnectAsync() {
await this.client.unbind();
}
}