This commit is contained in:
Bastian Wagner
2026-07-15 14:09:28 +02:00
commit 3e5348b7ec
104 changed files with 30367 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Client } from 'ldapts';
@Injectable()
export class LdapAuthService {
constructor(private readonly config: ConfigService) {}
async verifyPassword(username: string, password: string): Promise<boolean> {
const client = new Client({ url: this.config.getOrThrow<string>('LLDAP_LDAP_URL') });
try {
await client.bind(this.userDn(username), password);
return true;
} catch {
return false;
} finally {
await client.unbind().catch(() => undefined);
}
}
private userDn(username: string): string {
const baseDn = this.config.getOrThrow<string>('LLDAP_BASE_DN');
return `uid=${this.escapeDn(username)},ou=people,${baseDn}`;
}
private escapeDn(value: string): string {
return value.replace(/[\\,+"<>;=]/g, (char) => `\\${char}`);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { LdapAuthService } from './ldap-auth.service';
import { LldapService } from './lldap.service';
@Module({
providers: [LdapAuthService, LldapService],
exports: [LdapAuthService, LldapService],
})
export class LldapModule {}

View File

@@ -0,0 +1,441 @@
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
interface LldapUserInput {
username: string;
email: string;
displayName: string;
password: string;
}
interface LldapUser {
id: string;
email?: string;
displayName?: string;
}
export interface LldapGroup {
id: number;
displayName: string;
creationDate: string;
uuid: string;
attributes: LldapAttributeValue[];
users?: LldapUser[];
}
export interface LldapUserUpdateInput {
email?: string;
displayName?: string;
firstName?: string;
lastName?: string;
avatar?: string | null;
}
export interface LldapAttributeSchema {
name: string;
attributeType: string;
isList: boolean;
isVisible: boolean;
isEditable: boolean;
isHardcoded: boolean;
isReadonly: boolean;
}
export interface LldapAttributeValue {
name: string;
value: string[];
schema: LldapAttributeSchema;
}
export interface LldapAccountGroup {
id: number;
displayName: string;
creationDate: string;
uuid: string;
attributes: LldapAttributeValue[];
}
export interface LldapAccountUser {
id: string;
email: string;
displayName: string;
firstName: string;
lastName: string;
avatar?: string | null;
creationDate: string;
uuid: string;
attributes: LldapAttributeValue[];
groups: LldapAccountGroup[];
}
@Injectable()
export class LldapService {
private cachedHeaders?: { expiresAt: number; headers: Record<string, string> };
constructor(private readonly config: ConfigService) {}
async createUser(input: LldapUserInput): Promise<void> {
await this.graphql(
`mutation CreateUser($user: CreateUserInput!) {
createUser(user: $user) { id }
}`,
{
user: {
id: input.username,
email: input.email,
displayName: input.displayName,
password: input.password,
},
},
);
const defaultGroup = this.config.get<string>('LLDAP_DEFAULT_GROUP');
if (defaultGroup) {
await this.addUserToGroup(input.username, defaultGroup);
}
}
async setPassword(username: string, password: string): Promise<void> {
await this.graphql(
`mutation SetPassword($userId: String!, $password: String!) {
setPassword(userId: $userId, password: $password)
}`,
{ userId: username, password },
);
}
async updateUser(username: string, input: LldapUserUpdateInput): Promise<void> {
await this.graphql(
`mutation UpdateUser($user: UpdateUserInput!) {
updateUser(user: $user) { ok }
}`,
{
user: {
id: username,
...input,
avatar: input.avatar === null ? '' : input.avatar,
},
},
);
}
async deleteUser(username: string): Promise<void> {
await this.graphql(
`mutation DeleteUser($userId: String!) {
deleteUser(userId: $userId) { ok }
}`,
{ userId: username },
);
}
async findUserByUsername(username: string): Promise<LldapUser | null> {
const response = await this.graphql<{ user: LldapUser | null }>(
`query User($id: String!) {
user(userId: $id) { id email displayName }
}`,
{ id: username },
);
return response.user ?? null;
}
async findUserByEmail(email: string): Promise<LldapUser | null> {
const response = await this.graphql<{ users: LldapUser[] }>(
`query Users($filters: RequestFilter) {
users(filters: $filters) { id email displayName }
}`,
{ filters: { eq: { field: 'email', value: email } } },
);
return response.users?.[0] ?? null;
}
async getAccount(username: string): Promise<LldapAccountUser> {
const response = await this.graphql<{ user: LldapAccountUser }>(
`query Account($id: String!) {
user(userId: $id) {
id
email
displayName
firstName
lastName
avatar
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
groups {
id
displayName
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}
}`,
{ id: username },
);
return response.user;
}
async listUsers(): Promise<LldapAccountUser[]> {
const response = await this.graphql<{ users: LldapAccountUser[] }>(
`query Users {
users {
id
email
displayName
firstName
lastName
avatar
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
groups {
id
displayName
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}
}`,
{},
);
return response.users;
}
async listGroups(): Promise<LldapGroup[]> {
const response = await this.graphql<{ groups: LldapGroup[] }>(
`query Groups {
groups {
id
displayName
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
users { id email displayName }
}
}`,
{},
);
return response.groups;
}
async getGroup(groupId: number): Promise<LldapGroup> {
const response = await this.graphql<{ group: LldapGroup }>(
`query Group($groupId: Int!) {
group(groupId: $groupId) {
id
displayName
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
users { id email displayName }
}
}`,
{ groupId },
);
return response.group;
}
async createGroup(displayName: string): Promise<LldapGroup> {
const response = await this.graphql<{ createGroupWithDetails: LldapGroup }>(
`mutation CreateGroup($request: CreateGroupInput!) {
createGroupWithDetails(request: $request) {
id
displayName
creationDate
uuid
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}`,
{ request: { displayName, attributes: [] } },
);
return response.createGroupWithDetails;
}
async updateGroup(groupId: number, displayName: string): Promise<void> {
await this.graphql(
`mutation UpdateGroup($group: UpdateGroupInput!) {
updateGroup(group: $group) { ok }
}`,
{ group: { id: groupId, displayName } },
);
}
async deleteGroup(groupId: number): Promise<void> {
await this.graphql(
`mutation DeleteGroup($groupId: Int!) {
deleteGroup(groupId: $groupId) { ok }
}`,
{ groupId },
);
}
async addUserToGroup(username: string, groupId: string | number): Promise<void> {
await this.graphql(
`mutation AddUserToGroup($userId: String!, $groupId: Int!) {
addUserToGroup(userId: $userId, groupId: $groupId) { ok }
}`,
{ userId: username, groupId: Number(groupId) },
);
}
async removeUserFromGroup(username: string, groupId: string | number): Promise<void> {
await this.graphql(
`mutation RemoveUserFromGroup($userId: String!, $groupId: Int!) {
removeUserFromGroup(userId: $userId, groupId: $groupId) { ok }
}`,
{ userId: username, groupId: Number(groupId) },
);
}
private async graphql<T = unknown>(query: string, variables: Record<string, unknown>): Promise<T> {
const endpoint = `${this.config.getOrThrow<string>('LLDAP_URL').replace(/\/$/, '')}/api/graphql`;
const headers = await this.adminHeaders();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...headers,
},
body: JSON.stringify({ query, variables }),
});
const payload = (await response.json().catch(() => ({}))) as {
data?: T;
errors?: Array<{ message?: string }>;
};
if (!response.ok || payload.errors?.length) {
const message = payload.errors?.map((error) => error.message).join('; ') || response.statusText;
if (/not found/i.test(message)) {
throw new NotFoundException('LLDAP user not found');
}
throw new InternalServerErrorException(`LLDAP GraphQL request failed: ${message}`);
}
if (!payload.data) {
throw new InternalServerErrorException('LLDAP GraphQL response did not contain data');
}
return payload.data;
}
private async adminHeaders(): Promise<Record<string, string>> {
const staticToken = this.config.get<string>('LLDAP_GRAPHQL_TOKEN');
if (staticToken) {
return { authorization: `Bearer ${staticToken}` };
}
if (this.cachedHeaders && this.cachedHeaders.expiresAt > Date.now()) {
return this.cachedHeaders.headers;
}
const baseUrl = this.config.getOrThrow<string>('LLDAP_URL').replace(/\/$/, '');
const response = await fetch(`${baseUrl}/auth/simple/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
username: this.config.getOrThrow<string>('LLDAP_ADMIN_USERNAME'),
password: this.config.getOrThrow<string>('LLDAP_ADMIN_PASSWORD'),
}),
});
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
const cookie = response.headers.get('set-cookie');
const token = typeof body.token === 'string' ? body.token : typeof body.jwt === 'string' ? body.jwt : undefined;
if (!response.ok || (!cookie && !token)) {
throw new InternalServerErrorException('LLDAP admin login failed');
}
const headers: Record<string, string> = token
? { authorization: `Bearer ${token}` }
: { cookie: cookie ?? '' };
this.cachedHeaders = { headers, expiresAt: Date.now() + 5 * 60_000 };
return headers;
}
}