initial
This commit is contained in:
441
apps/api/src/lldap/lldap.service.ts
Normal file
441
apps/api/src/lldap/lldap.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user