483 lines
13 KiB
TypeScript
483 lines
13 KiB
TypeScript
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { Ber, BerWriter, Client } from 'ldapts';
|
|
|
|
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 static readonly passwordModifyOid = '1.3.6.1.4.1.4203.1.11.1';
|
|
|
|
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,
|
|
},
|
|
},
|
|
);
|
|
|
|
try {
|
|
await this.setPassword(input.username, input.password);
|
|
} catch (error) {
|
|
await this.deleteUser(input.username).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
|
|
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> {
|
|
const client = new Client({ url: this.config.getOrThrow<string>('LLDAP_LDAP_URL') });
|
|
try {
|
|
await client.bind(this.adminDn(), this.config.getOrThrow<string>('LLDAP_ADMIN_PASSWORD'));
|
|
await client.exop(
|
|
LldapService.passwordModifyOid,
|
|
this.passwordModifyRequestValue(this.userDn(username), password),
|
|
);
|
|
} catch (error) {
|
|
throw new InternalServerErrorException(`LLDAP password change failed: ${this.errorMessage(error)}`);
|
|
} finally {
|
|
await client.unbind().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private passwordModifyRequestValue(userDn: string, newPassword: string): Buffer {
|
|
const writer = new BerWriter();
|
|
writer.startSequence(Ber.Sequence | Ber.Constructor);
|
|
writer.writeString(userDn, 0x80);
|
|
writer.writeString(newPassword, 0x82);
|
|
writer.endSequence();
|
|
return writer.buffer;
|
|
}
|
|
|
|
private adminDn(): string {
|
|
return this.userDn(this.config.getOrThrow<string>('LLDAP_ADMIN_USERNAME'));
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
|
|
private errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : 'unknown error';
|
|
}
|
|
}
|