mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-22 11:15:23 -04:00
feat(auth): refactor auth, add oidc
Some checks are pending
Docker / build-and-push (frontend) (push) Waiting to run
Docker / build-and-push (backend) (push) Waiting to run
Deploy HD2 docs to Netlify / Deploys to netlify (push) Waiting to run
E2E Tests / backend-sqlite (push) Waiting to run
E2E Tests / backend-mariadb (push) Waiting to run
E2E Tests / backend-postgres (push) Waiting to run
E2E Tests / Build test build of frontend (push) Waiting to run
E2E Tests / frontend-cypress (1) (push) Blocked by required conditions
E2E Tests / frontend-cypress (2) (push) Blocked by required conditions
E2E Tests / frontend-cypress (3) (push) Blocked by required conditions
Lint and check format / Lint files and check formatting (push) Waiting to run
REUSE Compliance Check / reuse (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Static Analysis / Njsscan code scanning (push) Waiting to run
Static Analysis / CodeQL analysis (push) Waiting to run
Run tests & build / Test and build with NodeJS 20 (push) Waiting to run
Some checks are pending
Docker / build-and-push (frontend) (push) Waiting to run
Docker / build-and-push (backend) (push) Waiting to run
Deploy HD2 docs to Netlify / Deploys to netlify (push) Waiting to run
E2E Tests / backend-sqlite (push) Waiting to run
E2E Tests / backend-mariadb (push) Waiting to run
E2E Tests / backend-postgres (push) Waiting to run
E2E Tests / Build test build of frontend (push) Waiting to run
E2E Tests / frontend-cypress (1) (push) Blocked by required conditions
E2E Tests / frontend-cypress (2) (push) Blocked by required conditions
E2E Tests / frontend-cypress (3) (push) Blocked by required conditions
Lint and check format / Lint files and check formatting (push) Waiting to run
REUSE Compliance Check / reuse (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Static Analysis / Njsscan code scanning (push) Waiting to run
Static Analysis / CodeQL analysis (push) Waiting to run
Run tests & build / Test and build with NodeJS 20 (push) Waiting to run
Thanks to all HedgeDoc team members for the time discussing, helping with weird Nest issues, providing feedback and suggestions! Co-authored-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
parent
1609f3e01f
commit
7f665fae4b
109 changed files with 2927 additions and 1700 deletions
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -8,123 +8,106 @@ import {
|
|||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Param,
|
||||
Post,
|
||||
Get,
|
||||
Put,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Session } from 'express-session';
|
||||
|
||||
import { IdentityService } from '../../../identity/identity.service';
|
||||
import { LdapLoginDto } from '../../../identity/ldap/ldap-login.dto';
|
||||
import { LdapAuthGuard } from '../../../identity/ldap/ldap.strategy';
|
||||
import { LocalAuthGuard } from '../../../identity/local/local.strategy';
|
||||
import { LoginDto } from '../../../identity/local/login.dto';
|
||||
import { RegisterDto } from '../../../identity/local/register.dto';
|
||||
import { UpdatePasswordDto } from '../../../identity/local/update-password.dto';
|
||||
import { SessionGuard } from '../../../identity/session.guard';
|
||||
import { OidcService } from '../../../identity/oidc/oidc.service';
|
||||
import { PendingUserConfirmationDto } from '../../../identity/pending-user-confirmation.dto';
|
||||
import { ProviderType } from '../../../identity/provider-type.enum';
|
||||
import {
|
||||
RequestWithSession,
|
||||
SessionGuard,
|
||||
} from '../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { SessionState } from '../../../sessions/session.service';
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { UsersService } from '../../../users/users.service';
|
||||
import { makeUsernameLowercase } from '../../../utils/username';
|
||||
import { LoginEnabledGuard } from '../../utils/login-enabled.guard';
|
||||
import { FullUserInfoDto } from '../../../users/user-info.dto';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
import { RegistrationEnabledGuard } from '../../utils/registration-enabled.guard';
|
||||
import { RequestUser } from '../../utils/request-user.decorator';
|
||||
|
||||
type RequestWithSession = Request & {
|
||||
session: SessionState;
|
||||
};
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private usersService: UsersService,
|
||||
private identityService: IdentityService,
|
||||
private oidcService: OidcService,
|
||||
) {
|
||||
this.logger.setContext(AuthController.name);
|
||||
}
|
||||
|
||||
@UseGuards(RegistrationEnabledGuard)
|
||||
@Post('local')
|
||||
@OpenApi(201, 400, 403, 409)
|
||||
async registerUser(
|
||||
@Req() request: RequestWithSession,
|
||||
@Body() registerDto: RegisterDto,
|
||||
): Promise<void> {
|
||||
await this.identityService.checkPasswordStrength(registerDto.password);
|
||||
const user = await this.usersService.createUser(
|
||||
registerDto.username,
|
||||
registerDto.displayName,
|
||||
);
|
||||
await this.identityService.createLocalIdentity(user, registerDto.password);
|
||||
request.session.username = registerDto.username;
|
||||
request.session.authProvider = 'local';
|
||||
}
|
||||
|
||||
@UseGuards(LoginEnabledGuard, SessionGuard)
|
||||
@Put('local')
|
||||
@OpenApi(200, 400, 401)
|
||||
async updatePassword(
|
||||
@RequestUser() user: User,
|
||||
@Body() changePasswordDto: UpdatePasswordDto,
|
||||
): Promise<void> {
|
||||
await this.identityService.checkLocalPassword(
|
||||
user,
|
||||
changePasswordDto.currentPassword,
|
||||
);
|
||||
await this.identityService.updateLocalPassword(
|
||||
user,
|
||||
changePasswordDto.newPassword,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@UseGuards(LoginEnabledGuard, LocalAuthGuard)
|
||||
@Post('local/login')
|
||||
@OpenApi(201, 400, 401)
|
||||
login(
|
||||
@Req()
|
||||
request: RequestWithSession,
|
||||
@Body() loginDto: LoginDto,
|
||||
): void {
|
||||
// There is no further testing needed as we only get to this point if LocalAuthGuard was successful
|
||||
request.session.username = loginDto.username;
|
||||
request.session.authProvider = 'local';
|
||||
}
|
||||
|
||||
@UseGuards(LdapAuthGuard)
|
||||
@Post('ldap/:ldapIdentifier')
|
||||
@OpenApi(201, 400, 401)
|
||||
loginWithLdap(
|
||||
@Req()
|
||||
request: RequestWithSession,
|
||||
@Param('ldapIdentifier') ldapIdentifier: string,
|
||||
@Body() loginDto: LdapLoginDto,
|
||||
): void {
|
||||
// There is no further testing needed as we only get to this point if LdapAuthGuard was successful
|
||||
request.session.username = makeUsernameLowercase(loginDto.username);
|
||||
request.session.authProvider = 'ldap';
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Delete('logout')
|
||||
@OpenApi(204, 400, 401)
|
||||
logout(@Req() request: Request & { session: Session }): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.session.destroy((err) => {
|
||||
if (err) {
|
||||
this.logger.error('Encountered an error while logging out: ${err}');
|
||||
reject(new BadRequestException('Unable to log out'));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
@OpenApi(200, 400, 401)
|
||||
logout(@Req() request: RequestWithSession): { redirect: string } {
|
||||
let logoutUrl: string | null = null;
|
||||
if (request.session.authProviderType === ProviderType.OIDC) {
|
||||
logoutUrl = this.oidcService.getLogoutUrl(request);
|
||||
}
|
||||
request.session.destroy((err) => {
|
||||
if (err) {
|
||||
this.logger.error(
|
||||
'Error during logout:' + String(err),
|
||||
undefined,
|
||||
'logout',
|
||||
);
|
||||
throw new BadRequestException('Unable to log out');
|
||||
}
|
||||
});
|
||||
return {
|
||||
redirect: logoutUrl || '/',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('pending-user')
|
||||
@OpenApi(200, 400)
|
||||
getPendingUserData(
|
||||
@Req() request: RequestWithSession,
|
||||
): Partial<FullUserInfoDto> {
|
||||
if (
|
||||
!request.session.newUserData ||
|
||||
!request.session.authProviderIdentifier ||
|
||||
!request.session.authProviderType
|
||||
) {
|
||||
throw new BadRequestException('No pending user data');
|
||||
}
|
||||
return request.session.newUserData;
|
||||
}
|
||||
|
||||
@Put('pending-user')
|
||||
@OpenApi(204, 400)
|
||||
async confirmPendingUserData(
|
||||
@Req() request: RequestWithSession,
|
||||
@Body() updatedUserInfo: PendingUserConfirmationDto,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!request.session.newUserData ||
|
||||
!request.session.authProviderIdentifier ||
|
||||
!request.session.authProviderType ||
|
||||
!request.session.providerUserId
|
||||
) {
|
||||
throw new BadRequestException('No pending user data');
|
||||
}
|
||||
const identity = await this.identityService.createUserWithIdentity(
|
||||
request.session.newUserData,
|
||||
updatedUserInfo,
|
||||
request.session.authProviderType,
|
||||
request.session.authProviderIdentifier,
|
||||
request.session.providerUserId,
|
||||
);
|
||||
request.session.username = (await identity.user).username;
|
||||
// Cleanup
|
||||
request.session.newUserData = undefined;
|
||||
}
|
||||
|
||||
@Delete('pending-user')
|
||||
@OpenApi(204, 400)
|
||||
deletePendingUserData(@Req() request: RequestWithSession): void {
|
||||
request.session.newUserData = undefined;
|
||||
request.session.authProviderIdentifier = undefined;
|
||||
request.session.authProviderType = undefined;
|
||||
request.session.providerUserId = undefined;
|
||||
}
|
||||
}
|
||||
|
|
84
backend/src/api/private/auth/ldap/ldap.controller.ts
Normal file
84
backend/src/api/private/auth/ldap/ldap.controller.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
InternalServerErrorException,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { NotInDBError } from '../../../../errors/errors';
|
||||
import { IdentityService } from '../../../../identity/identity.service';
|
||||
import { LdapLoginDto } from '../../../../identity/ldap/ldap-login.dto';
|
||||
import { LdapService } from '../../../../identity/ldap/ldap.service';
|
||||
import { ProviderType } from '../../../../identity/provider-type.enum';
|
||||
import { RequestWithSession } from '../../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../../logger/console-logger.service';
|
||||
import { UsersService } from '../../../../users/users.service';
|
||||
import { makeUsernameLowercase } from '../../../../utils/username';
|
||||
import { OpenApi } from '../../../utils/openapi.decorator';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('/auth/ldap')
|
||||
export class LdapController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private usersService: UsersService,
|
||||
private ldapService: LdapService,
|
||||
private identityService: IdentityService,
|
||||
) {
|
||||
this.logger.setContext(LdapController.name);
|
||||
}
|
||||
|
||||
@Post(':ldapIdentifier/login')
|
||||
@OpenApi(200, 400, 401)
|
||||
async loginWithLdap(
|
||||
@Req()
|
||||
request: RequestWithSession,
|
||||
@Param('ldapIdentifier') ldapIdentifier: string,
|
||||
@Body() loginDto: LdapLoginDto,
|
||||
): Promise<{ newUser: boolean }> {
|
||||
const ldapConfig = this.ldapService.getLdapConfig(ldapIdentifier);
|
||||
const userInfo = await this.ldapService.getUserInfoFromLdap(
|
||||
ldapConfig,
|
||||
loginDto.username,
|
||||
loginDto.password,
|
||||
);
|
||||
try {
|
||||
request.session.authProviderType = ProviderType.LDAP;
|
||||
request.session.authProviderIdentifier = ldapIdentifier;
|
||||
request.session.providerUserId = userInfo.id;
|
||||
await this.identityService.getIdentityFromUserIdAndProviderType(
|
||||
userInfo.id,
|
||||
ProviderType.LDAP,
|
||||
ldapIdentifier,
|
||||
);
|
||||
if (this.identityService.mayUpdateIdentity(ldapIdentifier)) {
|
||||
const user = await this.usersService.getUserByUsername(
|
||||
makeUsernameLowercase(loginDto.username),
|
||||
);
|
||||
await this.usersService.updateUser(
|
||||
user,
|
||||
userInfo.displayName,
|
||||
userInfo.email,
|
||||
userInfo.photoUrl,
|
||||
);
|
||||
}
|
||||
request.session.username = makeUsernameLowercase(loginDto.username);
|
||||
return { newUser: false };
|
||||
} catch (error) {
|
||||
if (error instanceof NotInDBError) {
|
||||
request.session.newUserData = userInfo;
|
||||
return { newUser: true };
|
||||
}
|
||||
this.logger.error(`Error during LDAP login: ${String(error)}`);
|
||||
throw new InternalServerErrorException('Error during LDAP login');
|
||||
}
|
||||
}
|
||||
}
|
104
backend/src/api/private/auth/local/local.controller.ts
Normal file
104
backend/src/api/private/auth/local/local.controller.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { LocalService } from '../../../../identity/local/local.service';
|
||||
import { LoginDto } from '../../../../identity/local/login.dto';
|
||||
import { RegisterDto } from '../../../../identity/local/register.dto';
|
||||
import { UpdatePasswordDto } from '../../../../identity/local/update-password.dto';
|
||||
import { ProviderType } from '../../../../identity/provider-type.enum';
|
||||
import {
|
||||
RequestWithSession,
|
||||
SessionGuard,
|
||||
} from '../../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../../logger/console-logger.service';
|
||||
import { User } from '../../../../users/user.entity';
|
||||
import { UsersService } from '../../../../users/users.service';
|
||||
import { LoginEnabledGuard } from '../../../utils/login-enabled.guard';
|
||||
import { OpenApi } from '../../../utils/openapi.decorator';
|
||||
import { RegistrationEnabledGuard } from '../../../utils/registration-enabled.guard';
|
||||
import { RequestUser } from '../../../utils/request-user.decorator';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('/auth/local')
|
||||
export class LocalController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private usersService: UsersService,
|
||||
private localIdentityService: LocalService,
|
||||
) {
|
||||
this.logger.setContext(LocalController.name);
|
||||
}
|
||||
|
||||
@UseGuards(RegistrationEnabledGuard)
|
||||
@Post()
|
||||
@OpenApi(201, 400, 403, 409)
|
||||
async registerUser(
|
||||
@Req() request: RequestWithSession,
|
||||
@Body() registerDto: RegisterDto,
|
||||
): Promise<void> {
|
||||
await this.localIdentityService.checkPasswordStrength(registerDto.password);
|
||||
const user = await this.usersService.createUser(
|
||||
registerDto.username,
|
||||
registerDto.displayName,
|
||||
);
|
||||
await this.localIdentityService.createLocalIdentity(
|
||||
user,
|
||||
registerDto.password,
|
||||
);
|
||||
// Log the user in after registration
|
||||
request.session.authProviderType = ProviderType.LOCAL;
|
||||
request.session.username = registerDto.username;
|
||||
}
|
||||
|
||||
@UseGuards(LoginEnabledGuard, SessionGuard)
|
||||
@Put()
|
||||
@OpenApi(200, 400, 401)
|
||||
async updatePassword(
|
||||
@RequestUser() user: User,
|
||||
@Body() changePasswordDto: UpdatePasswordDto,
|
||||
): Promise<void> {
|
||||
await this.localIdentityService.checkLocalPassword(
|
||||
user,
|
||||
changePasswordDto.currentPassword,
|
||||
);
|
||||
await this.localIdentityService.updateLocalPassword(
|
||||
user,
|
||||
changePasswordDto.newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(LoginEnabledGuard)
|
||||
@Post('login')
|
||||
@OpenApi(201, 400, 401)
|
||||
async login(
|
||||
@Req()
|
||||
request: RequestWithSession,
|
||||
@Body() loginDto: LoginDto,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const user = await this.usersService.getUserByUsername(loginDto.username);
|
||||
await this.localIdentityService.checkLocalPassword(
|
||||
user,
|
||||
loginDto.password,
|
||||
);
|
||||
request.session.username = loginDto.username;
|
||||
request.session.authProviderType = ProviderType.LOCAL;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to log in user: ${String(error)}`);
|
||||
throw new UnauthorizedException('Invalid username or password');
|
||||
}
|
||||
}
|
||||
}
|
101
backend/src/api/private/auth/oidc/oidc.controller.ts
Normal file
101
backend/src/api/private/auth/oidc/oidc.controller.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Redirect,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { IdentityService } from '../../../../identity/identity.service';
|
||||
import { OidcService } from '../../../../identity/oidc/oidc.service';
|
||||
import { ProviderType } from '../../../../identity/provider-type.enum';
|
||||
import { RequestWithSession } from '../../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../../logger/console-logger.service';
|
||||
import { UsersService } from '../../../../users/users.service';
|
||||
import { OpenApi } from '../../../utils/openapi.decorator';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('/auth/oidc')
|
||||
export class OidcController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private usersService: UsersService,
|
||||
private identityService: IdentityService,
|
||||
private oidcService: OidcService,
|
||||
) {
|
||||
this.logger.setContext(OidcController.name);
|
||||
}
|
||||
|
||||
@Get(':oidcIdentifier')
|
||||
@Redirect()
|
||||
@OpenApi(201, 400, 401)
|
||||
loginWithOpenIdConnect(
|
||||
@Req() request: RequestWithSession,
|
||||
@Param('oidcIdentifier') oidcIdentifier: string,
|
||||
): { url: string } {
|
||||
const code = this.oidcService.generateCode();
|
||||
request.session.oidcLoginCode = code;
|
||||
request.session.authProviderType = ProviderType.OIDC;
|
||||
request.session.authProviderIdentifier = oidcIdentifier;
|
||||
const authorizationUrl = this.oidcService.getAuthorizationUrl(
|
||||
oidcIdentifier,
|
||||
code,
|
||||
);
|
||||
return { url: authorizationUrl };
|
||||
}
|
||||
|
||||
@Get(':oidcIdentifier/callback')
|
||||
@Redirect()
|
||||
@OpenApi(201, 400, 401)
|
||||
async callback(
|
||||
@Param('oidcIdentifier') oidcIdentifier: string,
|
||||
@Req() request: RequestWithSession,
|
||||
): Promise<{ url: string }> {
|
||||
try {
|
||||
const userInfo = await this.oidcService.extractUserInfoFromCallback(
|
||||
oidcIdentifier,
|
||||
request,
|
||||
);
|
||||
const oidcUserIdentifier = request.session.providerUserId;
|
||||
if (!oidcUserIdentifier) {
|
||||
throw new Error('No OIDC user identifier found');
|
||||
}
|
||||
const identity = await this.oidcService.getExistingOidcIdentity(
|
||||
oidcIdentifier,
|
||||
oidcUserIdentifier,
|
||||
);
|
||||
request.session.authProviderType = ProviderType.OIDC;
|
||||
const mayUpdate = this.identityService.mayUpdateIdentity(oidcIdentifier);
|
||||
if (identity !== null) {
|
||||
const user = await identity.user;
|
||||
if (mayUpdate) {
|
||||
await this.usersService.updateUser(
|
||||
user,
|
||||
userInfo.displayName,
|
||||
userInfo.email,
|
||||
userInfo.photoUrl,
|
||||
);
|
||||
}
|
||||
|
||||
request.session.username = user.username;
|
||||
return { url: '/' };
|
||||
} else {
|
||||
request.session.newUserData = userInfo;
|
||||
return { url: '/new-user' };
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.log(
|
||||
'Error during OIDC callback:' + String(error),
|
||||
'callback',
|
||||
);
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -75,6 +75,11 @@ export class MeController {
|
|||
@RequestUser() user: User,
|
||||
@Body('displayName') newDisplayName: string,
|
||||
): Promise<void> {
|
||||
await this.userService.changeDisplayName(user, newDisplayName);
|
||||
await this.userService.updateUser(
|
||||
user,
|
||||
newDisplayName,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -18,6 +18,9 @@ import { RevisionsModule } from '../../revisions/revisions.module';
|
|||
import { UsersModule } from '../../users/users.module';
|
||||
import { AliasController } from './alias/alias.controller';
|
||||
import { AuthController } from './auth/auth.controller';
|
||||
import { LdapController } from './auth/ldap/ldap.controller';
|
||||
import { LocalController } from './auth/local/local.controller';
|
||||
import { OidcController } from './auth/oidc/oidc.controller';
|
||||
import { ConfigController } from './config/config.controller';
|
||||
import { GroupsController } from './groups/groups.controller';
|
||||
import { HistoryController } from './me/history/history.controller';
|
||||
|
@ -52,6 +55,9 @@ import { UsersController } from './users/users.controller';
|
|||
AuthController,
|
||||
UsersController,
|
||||
GroupsController,
|
||||
LdapController,
|
||||
LocalController,
|
||||
OidcController,
|
||||
],
|
||||
})
|
||||
export class PrivateApiModule {}
|
||||
|
|
|
@ -3,11 +3,15 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { UserInfoDto } from '../../../users/user-info.dto';
|
||||
import {
|
||||
UsernameCheckDto,
|
||||
UsernameCheckResponseDto,
|
||||
} from '../../../users/username-check.dto';
|
||||
import { UsersService } from '../../../users/users.service';
|
||||
import { Username } from '../../../utils/username';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
|
@ -22,7 +26,20 @@ export class UsersController {
|
|||
this.logger.setContext(UsersController.name);
|
||||
}
|
||||
|
||||
@Get(':username')
|
||||
@Post('check')
|
||||
@HttpCode(200)
|
||||
@OpenApi(200)
|
||||
async checkUsername(
|
||||
@Body() usernameCheck: UsernameCheckDto,
|
||||
): Promise<UsernameCheckResponseDto> {
|
||||
const userExists = await this.userService.checkIfUserExists(
|
||||
usernameCheck.username,
|
||||
);
|
||||
// TODO Check if username is blocked
|
||||
return { usernameAvailable: !userExists };
|
||||
}
|
||||
|
||||
@Get('profile/:username')
|
||||
@OpenApi(200)
|
||||
async getUser(@Param('username') username: Username): Promise<UserInfoDto> {
|
||||
return this.userService.toUserDto(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -20,12 +20,12 @@ import { CompleteRequest } from './request.type';
|
|||
export const SessionAuthProvider = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request: CompleteRequest = ctx.switchToHttp().getRequest();
|
||||
if (!request.session?.authProvider) {
|
||||
if (!request.session?.authProviderType) {
|
||||
// We should have an auth provider here, otherwise something is wrong
|
||||
throw new InternalServerErrorException(
|
||||
'Session is missing an auth provider identifier',
|
||||
);
|
||||
}
|
||||
return request.session.authProvider;
|
||||
return request.session.authProviderType;
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import mockedEnv from 'mocked-env';
|
||||
|
||||
import authConfig from './auth.config';
|
||||
import { Theme } from './theme.enum';
|
||||
|
||||
describe('authConfig', () => {
|
||||
const secret = 'this-is-a-secret';
|
||||
|
@ -162,6 +163,7 @@ describe('authConfig', () => {
|
|||
const searchAttributes = ['mail', 'uid'];
|
||||
const userIdField = 'non_default_uid';
|
||||
const displayNameField = 'non_default_display_name';
|
||||
const emailField = 'non_default_email';
|
||||
const profilePictureField = 'non_default_profile_picture';
|
||||
const bindDn = 'cn=admin,dc=planetexpress,dc=com';
|
||||
const bindCredentials = 'GoodNewsEveryone';
|
||||
|
@ -176,6 +178,7 @@ describe('authConfig', () => {
|
|||
HD_AUTH_LDAP_FUTURAMA_SEARCH_FILTER: searchFilter,
|
||||
HD_AUTH_LDAP_FUTURAMA_SEARCH_ATTRIBUTES: searchAttributes.join(','),
|
||||
HD_AUTH_LDAP_FUTURAMA_USER_ID_FIELD: userIdField,
|
||||
HD_AUTH_LDAP_FUTURAMA_EMAIL_FIELD: emailField,
|
||||
HD_AUTH_LDAP_FUTURAMA_DISPLAY_NAME_FIELD: displayNameField,
|
||||
HD_AUTH_LDAP_FUTURAMA_PROFILE_PICTURE_FIELD: profilePictureField,
|
||||
HD_AUTH_LDAP_FUTURAMA_BIND_DN: bindDn,
|
||||
|
@ -199,7 +202,7 @@ describe('authConfig', () => {
|
|||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0]);
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
|
@ -207,6 +210,7 @@ describe('authConfig', () => {
|
|||
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
||||
expect(firstLdap.userIdField).toEqual(userIdField);
|
||||
expect(firstLdap.displayNameField).toEqual(displayNameField);
|
||||
expect(firstLdap.emailField).toEqual(emailField);
|
||||
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstLdap.bindDn).toEqual(bindDn);
|
||||
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
|
||||
|
@ -230,7 +234,7 @@ describe('authConfig', () => {
|
|||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0]);
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual('LDAP');
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
|
@ -261,7 +265,7 @@ describe('authConfig', () => {
|
|||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0]);
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
|
@ -292,7 +296,7 @@ describe('authConfig', () => {
|
|||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0]);
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
|
@ -323,7 +327,7 @@ describe('authConfig', () => {
|
|||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0]);
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
|
@ -354,7 +358,7 @@ describe('authConfig', () => {
|
|||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0]);
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
|
@ -385,7 +389,7 @@ describe('authConfig', () => {
|
|||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0]);
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
|
@ -416,7 +420,7 @@ describe('authConfig', () => {
|
|||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0]);
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
|
@ -447,7 +451,7 @@ describe('authConfig', () => {
|
|||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0]);
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
|
@ -519,4 +523,441 @@ describe('authConfig', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('odic', () => {
|
||||
const oidcNames = ['gitlab'];
|
||||
const providerName = 'Gitlab oAuth2';
|
||||
const issuer = 'https://gitlab.example.org';
|
||||
const clientId = '1234567890';
|
||||
const clientSecret = 'ABCDEF';
|
||||
const theme = Theme.GITHUB;
|
||||
const authorizeUrl = 'https://example.org/auth';
|
||||
const tokenUrl = 'https://example.org/token';
|
||||
const userinfoUrl = 'https://example.org/user';
|
||||
const scope = 'some scopr';
|
||||
const defaultScope = 'openid profile email';
|
||||
const userIdField = 'login';
|
||||
const defaultUserIdField = 'sub';
|
||||
const userNameField = 'preferred_username';
|
||||
const displayNameField = 'displayName';
|
||||
const defaultDisplayNameField = 'name';
|
||||
const profilePictureField = 'pictureField';
|
||||
const defaultProfilePictureField = 'picture';
|
||||
const emailField = 'a_email';
|
||||
const defaultEmailField = 'email';
|
||||
const completeOidcConfig = {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_AUTH_OIDC_SERVERS: oidcNames.join(','),
|
||||
HD_AUTH_OIDC_GITLAB_PROVIDER_NAME: providerName,
|
||||
HD_AUTH_OIDC_GITLAB_ISSUER: issuer,
|
||||
HD_AUTH_OIDC_GITLAB_CLIENT_ID: clientId,
|
||||
HD_AUTH_OIDC_GITLAB_CLIENT_SECRET: clientSecret,
|
||||
HD_AUTH_OIDC_GITLAB_THEME: theme,
|
||||
HD_AUTH_OIDC_GITLAB_AUTHORIZE_URL: authorizeUrl,
|
||||
HD_AUTH_OIDC_GITLAB_TOKEN_URL: tokenUrl,
|
||||
HD_AUTH_OIDC_GITLAB_USERINFO_URL: userinfoUrl,
|
||||
HD_AUTH_OIDC_GITLAB_SCOPE: scope,
|
||||
HD_AUTH_OIDC_GITLAB_USER_ID_FIELD: userIdField,
|
||||
HD_AUTH_OIDC_GITLAB_USER_NAME_FIELD: userNameField,
|
||||
HD_AUTH_OIDC_GITLAB_DISPLAY_NAME_FIELD: displayNameField,
|
||||
HD_AUTH_OIDC_GITLAB_PROFILE_PICTURE_FIELD: profilePictureField,
|
||||
HD_AUTH_OIDC_GITLAB_EMAIL_FIELD: emailField,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
};
|
||||
describe('is correctly parsed', () => {
|
||||
it('when given correct and complete environment variables', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.oidc).toHaveLength(1);
|
||||
const firstOidc = config.oidc[0];
|
||||
expect(firstOidc.identifier).toEqual(oidcNames[0]);
|
||||
expect(firstOidc.issuer).toEqual(issuer);
|
||||
expect(firstOidc.clientID).toEqual(clientId);
|
||||
expect(firstOidc.clientSecret).toEqual(clientSecret);
|
||||
expect(firstOidc.theme).toEqual(theme);
|
||||
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
|
||||
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
|
||||
expect(firstOidc.scope).toEqual(scope);
|
||||
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
|
||||
expect(firstOidc.userIdField).toEqual(userIdField);
|
||||
expect(firstOidc.userNameField).toEqual(userNameField);
|
||||
expect(firstOidc.displayNameField).toEqual(displayNameField);
|
||||
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstOidc.emailField).toEqual(emailField);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_THEME is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_THEME: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.oidc).toHaveLength(1);
|
||||
const firstOidc = config.oidc[0];
|
||||
expect(firstOidc.identifier).toEqual(oidcNames[0]);
|
||||
expect(firstOidc.issuer).toEqual(issuer);
|
||||
expect(firstOidc.clientID).toEqual(clientId);
|
||||
expect(firstOidc.clientSecret).toEqual(clientSecret);
|
||||
expect(firstOidc.theme).toBeUndefined();
|
||||
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
|
||||
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
|
||||
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
|
||||
expect(firstOidc.scope).toEqual(scope);
|
||||
expect(firstOidc.userIdField).toEqual(userIdField);
|
||||
expect(firstOidc.userNameField).toEqual(userNameField);
|
||||
expect(firstOidc.displayNameField).toEqual(displayNameField);
|
||||
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstOidc.emailField).toEqual(emailField);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_AUTHORIZE_URL is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_AUTHORIZE_URL: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.oidc).toHaveLength(1);
|
||||
const firstOidc = config.oidc[0];
|
||||
expect(firstOidc.identifier).toEqual(oidcNames[0]);
|
||||
expect(firstOidc.issuer).toEqual(issuer);
|
||||
expect(firstOidc.clientID).toEqual(clientId);
|
||||
expect(firstOidc.clientSecret).toEqual(clientSecret);
|
||||
expect(firstOidc.theme).toEqual(theme);
|
||||
expect(firstOidc.authorizeUrl).toBeUndefined();
|
||||
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
|
||||
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
|
||||
expect(firstOidc.scope).toEqual(scope);
|
||||
expect(firstOidc.userIdField).toEqual(userIdField);
|
||||
expect(firstOidc.userNameField).toEqual(userNameField);
|
||||
expect(firstOidc.displayNameField).toEqual(displayNameField);
|
||||
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstOidc.emailField).toEqual(emailField);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_TOKEN_URL is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_TOKEN_URL: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.oidc).toHaveLength(1);
|
||||
const firstOidc = config.oidc[0];
|
||||
expect(firstOidc.identifier).toEqual(oidcNames[0]);
|
||||
expect(firstOidc.issuer).toEqual(issuer);
|
||||
expect(firstOidc.clientID).toEqual(clientId);
|
||||
expect(firstOidc.clientSecret).toEqual(clientSecret);
|
||||
expect(firstOidc.theme).toEqual(theme);
|
||||
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
|
||||
expect(firstOidc.tokenUrl).toBeUndefined();
|
||||
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
|
||||
expect(firstOidc.scope).toEqual(scope);
|
||||
expect(firstOidc.userIdField).toEqual(userIdField);
|
||||
expect(firstOidc.userNameField).toEqual(userNameField);
|
||||
expect(firstOidc.displayNameField).toEqual(displayNameField);
|
||||
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstOidc.emailField).toEqual(emailField);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_USERINFO_URL is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_USERINFO_URL: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.oidc).toHaveLength(1);
|
||||
const firstOidc = config.oidc[0];
|
||||
expect(firstOidc.identifier).toEqual(oidcNames[0]);
|
||||
expect(firstOidc.issuer).toEqual(issuer);
|
||||
expect(firstOidc.clientID).toEqual(clientId);
|
||||
expect(firstOidc.clientSecret).toEqual(clientSecret);
|
||||
expect(firstOidc.theme).toEqual(theme);
|
||||
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
|
||||
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
|
||||
expect(firstOidc.userinfoUrl).toBeUndefined();
|
||||
expect(firstOidc.scope).toEqual(scope);
|
||||
expect(firstOidc.userIdField).toEqual(userIdField);
|
||||
expect(firstOidc.userNameField).toEqual(userNameField);
|
||||
expect(firstOidc.displayNameField).toEqual(displayNameField);
|
||||
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstOidc.emailField).toEqual(emailField);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_SCOPE is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_SCOPE: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.oidc).toHaveLength(1);
|
||||
const firstOidc = config.oidc[0];
|
||||
expect(firstOidc.identifier).toEqual(oidcNames[0]);
|
||||
expect(firstOidc.issuer).toEqual(issuer);
|
||||
expect(firstOidc.clientID).toEqual(clientId);
|
||||
expect(firstOidc.clientSecret).toEqual(clientSecret);
|
||||
expect(firstOidc.theme).toEqual(theme);
|
||||
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
|
||||
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
|
||||
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
|
||||
expect(firstOidc.scope).toEqual(defaultScope);
|
||||
expect(firstOidc.userIdField).toEqual(userIdField);
|
||||
expect(firstOidc.userNameField).toEqual(userNameField);
|
||||
expect(firstOidc.displayNameField).toEqual(displayNameField);
|
||||
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstOidc.emailField).toEqual(emailField);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_USER_ID_FIELD is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_USER_ID_FIELD: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.oidc).toHaveLength(1);
|
||||
const firstOidc = config.oidc[0];
|
||||
expect(firstOidc.identifier).toEqual(oidcNames[0]);
|
||||
expect(firstOidc.issuer).toEqual(issuer);
|
||||
expect(firstOidc.clientID).toEqual(clientId);
|
||||
expect(firstOidc.clientSecret).toEqual(clientSecret);
|
||||
expect(firstOidc.theme).toEqual(theme);
|
||||
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
|
||||
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
|
||||
expect(firstOidc.scope).toEqual(scope);
|
||||
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
|
||||
expect(firstOidc.userIdField).toEqual(defaultUserIdField);
|
||||
expect(firstOidc.userNameField).toEqual(userNameField);
|
||||
expect(firstOidc.displayNameField).toEqual(displayNameField);
|
||||
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstOidc.emailField).toEqual(emailField);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_DISPLAY_NAME_FIELD is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_DISPLAY_NAME_FIELD: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.oidc).toHaveLength(1);
|
||||
const firstOidc = config.oidc[0];
|
||||
expect(firstOidc.identifier).toEqual(oidcNames[0]);
|
||||
expect(firstOidc.issuer).toEqual(issuer);
|
||||
expect(firstOidc.clientID).toEqual(clientId);
|
||||
expect(firstOidc.clientSecret).toEqual(clientSecret);
|
||||
expect(firstOidc.theme).toEqual(theme);
|
||||
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
|
||||
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
|
||||
expect(firstOidc.scope).toEqual(scope);
|
||||
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
|
||||
expect(firstOidc.userIdField).toEqual(userIdField);
|
||||
expect(firstOidc.userNameField).toEqual(userNameField);
|
||||
expect(firstOidc.displayNameField).toEqual(defaultDisplayNameField);
|
||||
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstOidc.emailField).toEqual(emailField);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_PROFILE_PICTURE_FIELD is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_PROFILE_PICTURE_FIELD: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.oidc).toHaveLength(1);
|
||||
const firstOidc = config.oidc[0];
|
||||
expect(firstOidc.identifier).toEqual(oidcNames[0]);
|
||||
expect(firstOidc.issuer).toEqual(issuer);
|
||||
expect(firstOidc.clientID).toEqual(clientId);
|
||||
expect(firstOidc.clientSecret).toEqual(clientSecret);
|
||||
expect(firstOidc.theme).toEqual(theme);
|
||||
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
|
||||
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
|
||||
expect(firstOidc.scope).toEqual(scope);
|
||||
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
|
||||
expect(firstOidc.userIdField).toEqual(userIdField);
|
||||
expect(firstOidc.userNameField).toEqual(userNameField);
|
||||
expect(firstOidc.displayNameField).toEqual(displayNameField);
|
||||
expect(firstOidc.profilePictureField).toEqual(
|
||||
defaultProfilePictureField,
|
||||
);
|
||||
expect(firstOidc.emailField).toEqual(emailField);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_EMAIL_FIELD is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_EMAIL_FIELD: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.oidc).toHaveLength(1);
|
||||
const firstOidc = config.oidc[0];
|
||||
expect(firstOidc.identifier).toEqual(oidcNames[0]);
|
||||
expect(firstOidc.issuer).toEqual(issuer);
|
||||
expect(firstOidc.clientID).toEqual(clientId);
|
||||
expect(firstOidc.clientSecret).toEqual(clientSecret);
|
||||
expect(firstOidc.theme).toEqual(theme);
|
||||
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
|
||||
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
|
||||
expect(firstOidc.scope).toEqual(scope);
|
||||
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
|
||||
expect(firstOidc.userIdField).toEqual(userIdField);
|
||||
expect(firstOidc.userNameField).toEqual(userNameField);
|
||||
expect(firstOidc.displayNameField).toEqual(displayNameField);
|
||||
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstOidc.emailField).toEqual(defaultEmailField);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
describe('throws error', () => {
|
||||
it('when HD_AUTH_OIDC_GITLAB_ISSUER is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_ISSUER: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => authConfig()).toThrow(
|
||||
'"HD_AUTH_OIDC_GITLAB_ISSUER" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_CLIENT_ID is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_CLIENT_ID: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => authConfig()).toThrow(
|
||||
'"HD_AUTH_OIDC_GITLAB_CLIENT_ID" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_CLIENT_SECRET is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_CLIENT_SECRET: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => authConfig()).toThrow(
|
||||
'"HD_AUTH_OIDC_GITLAB_CLIENT_SECRET" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_OIDC_GITLAB_THEME is set to a wrong value', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeOidcConfig,
|
||||
HD_AUTH_OIDC_GITLAB_THEME: 'something else',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => authConfig()).toThrow('"HD_AUTH_OIDC_GITLAB_THEME"');
|
||||
restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -7,7 +7,7 @@ import { registerAs } from '@nestjs/config';
|
|||
import * as fs from 'fs';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { GitlabScope } from './gitlab.enum';
|
||||
import { Theme } from './theme.enum';
|
||||
import {
|
||||
buildErrorMessage,
|
||||
ensureNoDuplicatesExist,
|
||||
|
@ -16,9 +16,12 @@ import {
|
|||
toArrayConfig,
|
||||
} from './utils';
|
||||
|
||||
export interface LDAPConfig {
|
||||
export interface InternalIdentifier {
|
||||
identifier: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export interface LDAPConfig extends InternalIdentifier {
|
||||
url: string;
|
||||
bindDn?: string;
|
||||
bindCredentials?: string;
|
||||
|
@ -27,11 +30,33 @@ export interface LDAPConfig {
|
|||
searchAttributes: string[];
|
||||
userIdField: string;
|
||||
displayNameField: string;
|
||||
emailField: string;
|
||||
profilePictureField: string;
|
||||
tlsCaCerts?: string[];
|
||||
}
|
||||
|
||||
export interface OidcConfig extends InternalIdentifier {
|
||||
issuer: string;
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
theme?: string;
|
||||
authorizeUrl?: string;
|
||||
tokenUrl?: string;
|
||||
userinfoUrl?: string;
|
||||
scope: string;
|
||||
userNameField: string;
|
||||
userIdField: string;
|
||||
displayNameField: string;
|
||||
profilePictureField: string;
|
||||
emailField: string;
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
common: {
|
||||
allowProfileEdits: boolean;
|
||||
allowChooseUsername: boolean;
|
||||
syncSource?: string;
|
||||
};
|
||||
session: {
|
||||
secret: string;
|
||||
lifetime: number;
|
||||
|
@ -41,66 +66,27 @@ export interface AuthConfig {
|
|||
enableRegister: boolean;
|
||||
minimalPasswordStrength: number;
|
||||
};
|
||||
github: {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
google: {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
apiKey: string;
|
||||
};
|
||||
gitlab: {
|
||||
identifier: string;
|
||||
providerName: string;
|
||||
baseURL: string;
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
scope: GitlabScope;
|
||||
}[];
|
||||
// ToDo: tlsOptions exist in config.json.example. See https://nodejs.org/api/tls.html#tls_tls_connect_options_callback
|
||||
ldap: LDAPConfig[];
|
||||
saml: {
|
||||
identifier: string;
|
||||
providerName: string;
|
||||
idpSsoUrl: string;
|
||||
idpCert: string;
|
||||
clientCert: string;
|
||||
issuer: string;
|
||||
identifierFormat: string;
|
||||
disableRequestedAuthnContext: string;
|
||||
groupAttribute: string;
|
||||
requiredGroups?: string[];
|
||||
externalGroups?: string[];
|
||||
attribute: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
}[];
|
||||
oauth2: {
|
||||
identifier: string;
|
||||
providerName: string;
|
||||
baseURL: string;
|
||||
userProfileURL: string;
|
||||
userProfileIdAttr: string;
|
||||
userProfileUsernameAttr: string;
|
||||
userProfileDisplayNameAttr: string;
|
||||
userProfileEmailAttr: string;
|
||||
tokenURL: string;
|
||||
authorizationURL: string;
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
scope: string;
|
||||
rolesClaim: string;
|
||||
accessRole: string;
|
||||
}[];
|
||||
oidc: OidcConfig[];
|
||||
}
|
||||
|
||||
const authSchema = Joi.object({
|
||||
common: {
|
||||
allowProfileEdits: Joi.boolean()
|
||||
.default(true)
|
||||
.optional()
|
||||
.label('HD_AUTH_ALLOW_PROFILE_EDITS'),
|
||||
allowChooseUsername: Joi.boolean()
|
||||
.default(true)
|
||||
.optional()
|
||||
.label('HD_AUTH_ALLOW_CHOOSE_USERNAME'),
|
||||
syncSource: Joi.string().optional().label('HD_AUTH_SYNC_SOURCE'),
|
||||
},
|
||||
session: {
|
||||
secret: Joi.string().label('HD_SESSION_SECRET'),
|
||||
lifetime: Joi.number()
|
||||
.default(1209600000) // 14 * 24 * 60 * 60 * 1000ms = 14 days
|
||||
.default(1209600) // 14 * 24 * 60 * 60s = 14 days
|
||||
.optional()
|
||||
.label('HD_SESSION_LIFETIME'),
|
||||
},
|
||||
|
@ -120,30 +106,6 @@ const authSchema = Joi.object({
|
|||
.optional()
|
||||
.label('HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH'),
|
||||
},
|
||||
github: {
|
||||
clientID: Joi.string().optional().label('HD_AUTH_GITHUB_CLIENT_ID'),
|
||||
clientSecret: Joi.string().optional().label('HD_AUTH_GITHUB_CLIENT_SECRET'),
|
||||
},
|
||||
google: {
|
||||
clientID: Joi.string().optional().label('HD_AUTH_GOOGLE_CLIENT_ID'),
|
||||
clientSecret: Joi.string().optional().label('HD_AUTH_GOOGLE_CLIENT_SECRET'),
|
||||
apiKey: Joi.string().optional().label('HD_AUTH_GOOGLE_APP_KEY'),
|
||||
},
|
||||
gitlab: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
identifier: Joi.string(),
|
||||
providerName: Joi.string().default('Gitlab').optional(),
|
||||
baseURL: Joi.string(),
|
||||
clientID: Joi.string(),
|
||||
clientSecret: Joi.string(),
|
||||
scope: Joi.string()
|
||||
.valid(...Object.values(GitlabScope))
|
||||
.default(GitlabScope.READ_USER)
|
||||
.optional(),
|
||||
}).optional(),
|
||||
)
|
||||
.optional(),
|
||||
ldap: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
|
@ -157,107 +119,49 @@ const authSchema = Joi.object({
|
|||
searchAttributes: Joi.array().items(Joi.string()).optional(),
|
||||
userIdField: Joi.string().default('uid').optional(),
|
||||
displayNameField: Joi.string().default('displayName').optional(),
|
||||
emailField: Joi.string().default('mail').optional(),
|
||||
profilePictureField: Joi.string().default('jpegPhoto').optional(),
|
||||
tlsCaCerts: Joi.array().items(Joi.string()).optional(),
|
||||
}).optional(),
|
||||
)
|
||||
.optional(),
|
||||
saml: Joi.array()
|
||||
oidc: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
identifier: Joi.string(),
|
||||
providerName: Joi.string().default('SAML').optional(),
|
||||
idpSsoUrl: Joi.string(),
|
||||
idpCert: Joi.string(),
|
||||
clientCert: Joi.string().optional(),
|
||||
issuer: Joi.string().optional(),
|
||||
identifierFormat: Joi.string()
|
||||
.default('urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress')
|
||||
.optional(),
|
||||
disableRequestedAuthnContext: Joi.boolean().default(false).optional(),
|
||||
groupAttribute: Joi.string().optional(),
|
||||
requiredGroups: Joi.array().items(Joi.string()).optional(),
|
||||
externalGroups: Joi.array().items(Joi.string()).optional(),
|
||||
attribute: {
|
||||
id: Joi.string().default('NameId').optional(),
|
||||
username: Joi.string().default('NameId').optional(),
|
||||
local: Joi.string().default('NameId').optional(),
|
||||
},
|
||||
}).optional(),
|
||||
)
|
||||
.optional(),
|
||||
oauth2: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
identifier: Joi.string(),
|
||||
providerName: Joi.string().default('OAuth2').optional(),
|
||||
baseURL: Joi.string(),
|
||||
userProfileURL: Joi.string(),
|
||||
userProfileIdAttr: Joi.string().optional(),
|
||||
userProfileUsernameAttr: Joi.string(),
|
||||
userProfileDisplayNameAttr: Joi.string(),
|
||||
userProfileEmailAttr: Joi.string(),
|
||||
tokenURL: Joi.string(),
|
||||
authorizationURL: Joi.string(),
|
||||
providerName: Joi.string().default('OpenID Connect').optional(),
|
||||
issuer: Joi.string(),
|
||||
clientID: Joi.string(),
|
||||
clientSecret: Joi.string(),
|
||||
scope: Joi.string().optional(),
|
||||
rolesClaim: Joi.string().optional(),
|
||||
accessRole: Joi.string().optional(),
|
||||
theme: Joi.string()
|
||||
.valid(...Object.values(Theme))
|
||||
.optional(),
|
||||
authorizeUrl: Joi.string().optional(),
|
||||
tokenUrl: Joi.string().optional(),
|
||||
userinfoUrl: Joi.string().optional(),
|
||||
scope: Joi.string().default('openid profile email').optional(),
|
||||
userIdField: Joi.string().default('sub').optional(),
|
||||
userNameField: Joi.string().default('preferred_username').optional(),
|
||||
displayNameField: Joi.string().default('name').optional(),
|
||||
profilePictureField: Joi.string().default('picture').optional(),
|
||||
emailField: Joi.string().default('email').optional(),
|
||||
}).optional(),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export default registerAs('authConfig', () => {
|
||||
const gitlabNames = (
|
||||
toArrayConfig(process.env.HD_AUTH_GITLABS, ',') ?? []
|
||||
).map((name) => name.toUpperCase());
|
||||
if (gitlabNames.length !== 0) {
|
||||
throw new Error(
|
||||
"GitLab auth is currently not yet supported. Please don't configure it",
|
||||
);
|
||||
}
|
||||
ensureNoDuplicatesExist('GitLab', gitlabNames);
|
||||
|
||||
const ldapNames = (
|
||||
toArrayConfig(process.env.HD_AUTH_LDAP_SERVERS, ',') ?? []
|
||||
).map((name) => name.toUpperCase());
|
||||
ensureNoDuplicatesExist('LDAP', ldapNames);
|
||||
|
||||
const samlNames = (toArrayConfig(process.env.HD_AUTH_SAMLS, ',') ?? []).map(
|
||||
(name) => name.toUpperCase(),
|
||||
);
|
||||
if (samlNames.length !== 0) {
|
||||
throw new Error(
|
||||
"SAML auth is currently not yet supported. Please don't configure it",
|
||||
);
|
||||
}
|
||||
ensureNoDuplicatesExist('SAML', samlNames);
|
||||
|
||||
const oauth2Names = (
|
||||
toArrayConfig(process.env.HD_AUTH_OAUTH2S, ',') ?? []
|
||||
const oidcNames = (
|
||||
toArrayConfig(process.env.HD_AUTH_OIDC_SERVERS, ',') ?? []
|
||||
).map((name) => name.toUpperCase());
|
||||
if (oauth2Names.length !== 0) {
|
||||
throw new Error(
|
||||
"OAuth2 auth is currently not yet supported. Please don't configure it",
|
||||
);
|
||||
}
|
||||
ensureNoDuplicatesExist('OAuth2', oauth2Names);
|
||||
ensureNoDuplicatesExist('OIDC', oidcNames);
|
||||
|
||||
const gitlabs = gitlabNames.map((gitlabName) => {
|
||||
return {
|
||||
identifier: gitlabName,
|
||||
providerName: process.env[`HD_AUTH_GITLAB_${gitlabName}_PROVIDER_NAME`],
|
||||
baseURL: process.env[`HD_AUTH_GITLAB_${gitlabName}_BASE_URL`],
|
||||
clientID: process.env[`HD_AUTH_GITLAB_${gitlabName}_CLIENT_ID`],
|
||||
clientSecret: process.env[`HD_AUTH_GITLAB_${gitlabName}_CLIENT_SECRET`],
|
||||
scope: process.env[`HD_AUTH_GITLAB_${gitlabName}_SCOPE`],
|
||||
version: process.env[`HD_AUTH_GITLAB_${gitlabName}_GITLAB_VERSION`],
|
||||
};
|
||||
});
|
||||
|
||||
const ldaps = ldapNames.map((ldapName) => {
|
||||
const ldapInstances = ldapNames.map((ldapName) => {
|
||||
const caFiles = toArrayConfig(
|
||||
process.env[`HD_AUTH_LDAP_${ldapName}_TLS_CERT_PATHS`],
|
||||
',',
|
||||
|
@ -271,7 +175,7 @@ export default registerAs('authConfig', () => {
|
|||
});
|
||||
}
|
||||
return {
|
||||
identifier: ldapName,
|
||||
identifier: ldapName.toLowerCase(),
|
||||
providerName: process.env[`HD_AUTH_LDAP_${ldapName}_PROVIDER_NAME`],
|
||||
url: process.env[`HD_AUTH_LDAP_${ldapName}_URL`],
|
||||
bindDn: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_DN`],
|
||||
|
@ -285,92 +189,45 @@ export default registerAs('authConfig', () => {
|
|||
userIdField: process.env[`HD_AUTH_LDAP_${ldapName}_USER_ID_FIELD`],
|
||||
displayNameField:
|
||||
process.env[`HD_AUTH_LDAP_${ldapName}_DISPLAY_NAME_FIELD`],
|
||||
emailField: process.env[`HD_AUTH_LDAP_${ldapName}_EMAIL_FIELD`],
|
||||
profilePictureField:
|
||||
process.env[`HD_AUTH_LDAP_${ldapName}_PROFILE_PICTURE_FIELD`],
|
||||
tlsCaCerts: tlsCaCerts,
|
||||
};
|
||||
});
|
||||
|
||||
const samls = samlNames.map((samlName) => {
|
||||
return {
|
||||
identifier: samlName,
|
||||
providerName: process.env[`HD_AUTH_SAML_${samlName}_PROVIDER_NAME`],
|
||||
idpSsoUrl: process.env[`HD_AUTH_SAML_${samlName}_IDP_SSO_URL`],
|
||||
idpCert: process.env[`HD_AUTH_SAML_${samlName}_IDP_CERT`],
|
||||
clientCert: process.env[`HD_AUTH_SAML_${samlName}_CLIENT_CERT`],
|
||||
// ToDo: (default: config.serverURL) will be build on-the-fly in the config/index.js from domain, urlAddPort and urlPath.
|
||||
// https://github.com/hedgedoc/hedgedoc/issues/5043
|
||||
issuer: process.env[`HD_AUTH_SAML_${samlName}_ISSUER`],
|
||||
identifierFormat:
|
||||
process.env[`HD_AUTH_SAML_${samlName}_IDENTIFIER_FORMAT`],
|
||||
disableRequestedAuthnContext:
|
||||
process.env[`HD_AUTH_SAML_${samlName}_DISABLE_REQUESTED_AUTHN_CONTEXT`],
|
||||
groupAttribute: process.env[`HD_AUTH_SAML_${samlName}_GROUP_ATTRIBUTE`],
|
||||
requiredGroups: toArrayConfig(
|
||||
process.env[`HD_AUTH_SAML_${samlName}_REQUIRED_GROUPS`],
|
||||
'|',
|
||||
),
|
||||
externalGroups: toArrayConfig(
|
||||
process.env[`HD_AUTH_SAML_${samlName}_EXTERNAL_GROUPS`],
|
||||
'|',
|
||||
),
|
||||
attribute: {
|
||||
id: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_ID`],
|
||||
username: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_USERNAME`],
|
||||
local: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_LOCAL`],
|
||||
},
|
||||
};
|
||||
});
|
||||
const oidcInstances = oidcNames.map((oidcName) => ({
|
||||
identifier: oidcName.toLowerCase(),
|
||||
providerName: process.env[`HD_AUTH_OIDC_${oidcName}_PROVIDER_NAME`],
|
||||
issuer: process.env[`HD_AUTH_OIDC_${oidcName}_ISSUER`],
|
||||
clientID: process.env[`HD_AUTH_OIDC_${oidcName}_CLIENT_ID`],
|
||||
clientSecret: process.env[`HD_AUTH_OIDC_${oidcName}_CLIENT_SECRET`],
|
||||
theme: process.env[`HD_AUTH_OIDC_${oidcName}_THEME`],
|
||||
authorizeUrl: process.env[`HD_AUTH_OIDC_${oidcName}_AUTHORIZE_URL`],
|
||||
tokenUrl: process.env[`HD_AUTH_OIDC_${oidcName}_TOKEN_URL`],
|
||||
userinfoUrl: process.env[`HD_AUTH_OIDC_${oidcName}_USERINFO_URL`],
|
||||
scope: process.env[`HD_AUTH_OIDC_${oidcName}_SCOPE`],
|
||||
userIdField: process.env[`HD_AUTH_OIDC_${oidcName}_USER_ID_FIELD`],
|
||||
userNameField: process.env[`HD_AUTH_OIDC_${oidcName}_USER_NAME_FIELD`],
|
||||
displayNameField:
|
||||
process.env[`HD_AUTH_OIDC_${oidcName}_DISPLAY_NAME_FIELD`],
|
||||
profilePictureField:
|
||||
process.env[`HD_AUTH_OIDC_${oidcName}_PROFILE_PICTURE_FIELD`],
|
||||
emailField: process.env[`HD_AUTH_OIDC_${oidcName}_EMAIL_FIELD`],
|
||||
}));
|
||||
|
||||
const oauth2s = oauth2Names.map((oauth2Name) => {
|
||||
return {
|
||||
identifier: oauth2Name,
|
||||
providerName: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_PROVIDER_NAME`],
|
||||
baseURL: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_BASE_URL`],
|
||||
userProfileURL:
|
||||
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_URL`],
|
||||
userProfileIdAttr:
|
||||
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_ID_ATTR`],
|
||||
userProfileUsernameAttr:
|
||||
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_USERNAME_ATTR`],
|
||||
userProfileDisplayNameAttr:
|
||||
process.env[
|
||||
`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_DISPLAY_NAME_ATTR`
|
||||
],
|
||||
userProfileEmailAttr:
|
||||
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_EMAIL_ATTR`],
|
||||
tokenURL: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_TOKEN_URL`],
|
||||
authorizationURL:
|
||||
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_AUTHORIZATION_URL`],
|
||||
clientID: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_CLIENT_ID`],
|
||||
clientSecret: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_CLIENT_SECRET`],
|
||||
scope: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_SCOPE`],
|
||||
rolesClaim: process.env[`HD_AUTH_OAUTH2_${oauth2Name}`],
|
||||
accessRole: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_ACCESS_ROLE`],
|
||||
};
|
||||
});
|
||||
|
||||
if (
|
||||
process.env.HD_AUTH_GITHUB_CLIENT_ID !== undefined ||
|
||||
process.env.HD_AUTH_GITHUB_CLIENT_SECRET !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"GitHub config is currently not yet supported. Please don't configure it",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.HD_AUTH_GOOGLE_CLIENT_ID !== undefined ||
|
||||
process.env.HD_AUTH_GOOGLE_CLIENT_SECRET !== undefined ||
|
||||
process.env.HD_AUTH_GOOGLE_APP_KEY !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"Google config is currently not yet supported. Please don't configure it",
|
||||
);
|
||||
let syncSource = process.env.HD_AUTH_SYNC_SOURCE;
|
||||
if (syncSource !== undefined) {
|
||||
syncSource = syncSource.toLowerCase();
|
||||
}
|
||||
|
||||
const authConfig = authSchema.validate(
|
||||
{
|
||||
common: {
|
||||
allowProfileEdits: process.env.HD_AUTH_ALLOW_PROFILE_EDITS,
|
||||
allowChooseUsername: process.env.HD_AUTH_ALLOW_CHOOSE_USERNAME,
|
||||
syncSource: syncSource,
|
||||
},
|
||||
session: {
|
||||
secret: process.env.HD_SESSION_SECRET,
|
||||
lifetime: parseOptionalNumber(process.env.HD_SESSION_LIFETIME),
|
||||
|
@ -382,19 +239,8 @@ export default registerAs('authConfig', () => {
|
|||
process.env.HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH,
|
||||
),
|
||||
},
|
||||
github: {
|
||||
clientID: process.env.HD_AUTH_GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.HD_AUTH_GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
google: {
|
||||
clientID: process.env.HD_AUTH_GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.HD_AUTH_GOOGLE_CLIENT_SECRET,
|
||||
apiKey: process.env.HD_AUTH_GOOGLE_APP_KEY,
|
||||
},
|
||||
gitlab: gitlabs,
|
||||
ldap: ldaps,
|
||||
saml: samls,
|
||||
oauth2: oauth2s,
|
||||
ldap: ldapInstances,
|
||||
oidc: oidcInstances,
|
||||
},
|
||||
{
|
||||
abortEarly: false,
|
||||
|
@ -404,14 +250,6 @@ export default registerAs('authConfig', () => {
|
|||
if (authConfig.error) {
|
||||
const errorMessages = authConfig.error.details
|
||||
.map((detail) => detail.message)
|
||||
.map((error) =>
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
error,
|
||||
'gitlab',
|
||||
'HD_AUTH_GITLAB_',
|
||||
gitlabNames,
|
||||
),
|
||||
)
|
||||
.map((error) =>
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
error,
|
||||
|
@ -423,17 +261,9 @@ export default registerAs('authConfig', () => {
|
|||
.map((error) =>
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
error,
|
||||
'saml',
|
||||
'HD_AUTH_SAML_',
|
||||
samlNames,
|
||||
),
|
||||
)
|
||||
.map((error) =>
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
error,
|
||||
'oauth2',
|
||||
'HD_AUTH_OAUTH2_',
|
||||
oauth2Names,
|
||||
'oidc',
|
||||
'HD_AUTH_OIDC_',
|
||||
oidcNames,
|
||||
),
|
||||
);
|
||||
throw new Error(buildErrorMessage(errorMessages));
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -10,6 +10,10 @@ import { AuthConfig } from '../auth.config';
|
|||
|
||||
export function createDefaultMockAuthConfig(): AuthConfig {
|
||||
return {
|
||||
common: {
|
||||
allowProfileEdits: true,
|
||||
allowChooseUsername: true,
|
||||
},
|
||||
session: {
|
||||
secret: 'my_secret',
|
||||
lifetime: 1209600000,
|
||||
|
@ -19,19 +23,8 @@ export function createDefaultMockAuthConfig(): AuthConfig {
|
|||
enableRegister: true,
|
||||
minimalPasswordStrength: 2,
|
||||
},
|
||||
github: {
|
||||
clientID: '',
|
||||
clientSecret: '',
|
||||
},
|
||||
google: {
|
||||
clientID: '',
|
||||
clientSecret: '',
|
||||
apiKey: '',
|
||||
},
|
||||
gitlab: [],
|
||||
ldap: [],
|
||||
saml: [],
|
||||
oauth2: [],
|
||||
oidc: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
15
backend/src/config/theme.enum.ts
Normal file
15
backend/src/config/theme.enum.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export enum Theme {
|
||||
GOOGLE = 'google',
|
||||
GITHUB = 'github',
|
||||
GITLAB = 'gitlab',
|
||||
FACEBOOK = 'facebook',
|
||||
DISCORD = 'discord',
|
||||
MASTODON = 'mastodon',
|
||||
AZURE = 'azure',
|
||||
}
|
|
@ -67,16 +67,6 @@ describe('config utils', () => {
|
|||
});
|
||||
});
|
||||
describe('replaceAuthErrorsWithEnvironmentVariables', () => {
|
||||
it('"gitlab[0].scope', () => {
|
||||
expect(
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
'"gitlab[0].scope',
|
||||
'gitlab',
|
||||
'HD_AUTH_GITLAB_',
|
||||
['test'],
|
||||
),
|
||||
).toEqual('"HD_AUTH_GITLAB_test_SCOPE');
|
||||
});
|
||||
it('"ldap[0].url', () => {
|
||||
expect(
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -66,61 +66,24 @@ export function replaceAuthErrorsWithEnvironmentVariables(
|
|||
newMessage = newMessage.replace('.providerName', '_PROVIDER_NAME');
|
||||
newMessage = newMessage.replace('.baseURL', '_BASE_URL');
|
||||
newMessage = newMessage.replace('.clientID', '_CLIENT_ID');
|
||||
newMessage = newMessage.replace('.clientSecret', '_CLIENT_SECRET');
|
||||
newMessage = newMessage.replace('.scope', '_SCOPE');
|
||||
newMessage = newMessage.replace('.version', '_GITLAB_VERSION');
|
||||
newMessage = newMessage.replace('.url', '_URL');
|
||||
newMessage = newMessage.replace('.clientSecret', '_CLIENT_SECRET');
|
||||
newMessage = newMessage.replace('.bindDn', '_BIND_DN');
|
||||
newMessage = newMessage.replace('.bindCredentials', '_BIND_CREDENTIALS');
|
||||
newMessage = newMessage.replace('.searchBase', '_SEARCH_BASE');
|
||||
newMessage = newMessage.replace('.searchFilter', '_SEARCH_FILTER');
|
||||
newMessage = newMessage.replace('.searchAttributes', '_SEARCH_ATTRIBUTES');
|
||||
newMessage = newMessage.replace('.userIdField', '_USER_ID_FIELD');
|
||||
newMessage = newMessage.replace('.userNameField', '_USER_NAME_FIELD');
|
||||
newMessage = newMessage.replace('.displayNameField', '_DISPLAY_NAME_FIELD');
|
||||
newMessage = newMessage.replace('.emailField', '_EMAIL_FIELD');
|
||||
newMessage = newMessage.replace(
|
||||
'.profilePictureField',
|
||||
'_PROFILE_PICTURE_FIELD',
|
||||
);
|
||||
newMessage = newMessage.replace('.tlsCaCerts', '_TLS_CERT_PATHS');
|
||||
newMessage = newMessage.replace('.idpSsoUrl', '_IDP_SSO_URL');
|
||||
newMessage = newMessage.replace('.idpCert', '_IDP_CERT');
|
||||
newMessage = newMessage.replace('.clientCert', '_CLIENT_CERT');
|
||||
newMessage = newMessage.replace('.issuer', '_ISSUER');
|
||||
newMessage = newMessage.replace('.identifierFormat', '_IDENTIFIER_FORMAT');
|
||||
newMessage = newMessage.replace(
|
||||
'.disableRequestedAuthnContext',
|
||||
'_DISABLE_REQUESTED_AUTHN_CONTEXT',
|
||||
);
|
||||
newMessage = newMessage.replace('.groupAttribute', '_GROUP_ATTRIBUTE');
|
||||
newMessage = newMessage.replace('.requiredGroups', '_REQUIRED_GROUPS');
|
||||
newMessage = newMessage.replace('.externalGroups', '_EXTERNAL_GROUPS');
|
||||
newMessage = newMessage.replace('.attribute.id', '_ATTRIBUTE_ID');
|
||||
newMessage = newMessage.replace(
|
||||
'.attribute.username',
|
||||
'_ATTRIBUTE_USERNAME',
|
||||
);
|
||||
newMessage = newMessage.replace('.attribute.local', '_ATTRIBUTE_LOCAL');
|
||||
newMessage = newMessage.replace('.userProfileURL', '_USER_PROFILE_URL');
|
||||
newMessage = newMessage.replace(
|
||||
'.userProfileIdAttr',
|
||||
'_USER_PROFILE_ID_ATTR',
|
||||
);
|
||||
newMessage = newMessage.replace(
|
||||
'.userProfileUsernameAttr',
|
||||
'_USER_PROFILE_USERNAME_ATTR',
|
||||
);
|
||||
newMessage = newMessage.replace(
|
||||
'.userProfileDisplayNameAttr',
|
||||
'_USER_PROFILE_DISPLAY_NAME_ATTR',
|
||||
);
|
||||
newMessage = newMessage.replace(
|
||||
'.userProfileEmailAttr',
|
||||
'_USER_PROFILE_EMAIL_ATTR',
|
||||
);
|
||||
newMessage = newMessage.replace('.tokenURL', '_TOKEN_URL');
|
||||
newMessage = newMessage.replace('.authorizationURL', '_AUTHORIZATION_URL');
|
||||
newMessage = newMessage.replace('.rolesClaim', '_ROLES_CLAIM');
|
||||
newMessage = newMessage.replace('.accessRole', '_ACCESS_ROLE');
|
||||
newMessage = newMessage.replace('.theme', '_THEME');
|
||||
}
|
||||
return newMessage;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -16,29 +16,15 @@ import {
|
|||
import { URL } from 'url';
|
||||
|
||||
import { GuestAccess } from '../config/guest_access.enum';
|
||||
import { ProviderType } from '../identity/provider-type.enum';
|
||||
import { ServerVersion } from '../monitoring/server-status.dto';
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export enum AuthProviderType {
|
||||
LOCAL = 'local',
|
||||
LDAP = 'ldap',
|
||||
SAML = 'saml',
|
||||
OAUTH2 = 'oauth2',
|
||||
GITLAB = 'gitlab',
|
||||
GITHUB = 'github',
|
||||
GOOGLE = 'google',
|
||||
}
|
||||
|
||||
export type AuthProviderTypeWithCustomName =
|
||||
| AuthProviderType.LDAP
|
||||
| AuthProviderType.OAUTH2
|
||||
| AuthProviderType.SAML
|
||||
| AuthProviderType.GITLAB;
|
||||
| ProviderType.LDAP
|
||||
| ProviderType.OIDC;
|
||||
|
||||
export type AuthProviderTypeWithoutCustomName =
|
||||
| AuthProviderType.LOCAL
|
||||
| AuthProviderType.GITHUB
|
||||
| AuthProviderType.GOOGLE;
|
||||
export type AuthProviderTypeWithoutCustomName = ProviderType.LOCAL;
|
||||
|
||||
export class AuthProviderWithoutCustomNameDto extends BaseDto {
|
||||
/**
|
||||
|
@ -70,6 +56,14 @@ export class AuthProviderWithCustomNameDto extends BaseDto {
|
|||
*/
|
||||
@IsString()
|
||||
providerName: string;
|
||||
|
||||
/**
|
||||
* The theme to apply for the login button.
|
||||
* @example gitlab
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export type AuthProviderDto =
|
||||
|
@ -137,6 +131,18 @@ export class FrontendConfigDto extends BaseDto {
|
|||
@IsBoolean()
|
||||
allowRegister: boolean;
|
||||
|
||||
/**
|
||||
* Are users allowed to edit their profile information?
|
||||
*/
|
||||
@IsBoolean()
|
||||
allowProfileEdits: boolean;
|
||||
|
||||
/**
|
||||
* Are users allowed to choose their username when signing up via OIDC?
|
||||
*/
|
||||
@IsBoolean()
|
||||
allowChooseUsername: boolean;
|
||||
|
||||
/**
|
||||
* Which auth providers are enabled and how are they configured?
|
||||
*/
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -12,13 +12,12 @@ import { AuthConfig } from '../config/auth.config';
|
|||
import { CustomizationConfig } from '../config/customization.config';
|
||||
import { DefaultAccessLevel } from '../config/default-access-level.enum';
|
||||
import { ExternalServicesConfig } from '../config/external-services.config';
|
||||
import { GitlabScope } from '../config/gitlab.enum';
|
||||
import { GuestAccess } from '../config/guest_access.enum';
|
||||
import { Loglevel } from '../config/loglevel.enum';
|
||||
import { NoteConfig } from '../config/note.config';
|
||||
import { ProviderType } from '../identity/provider-type.enum';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { getServerVersionFromPackageJson } from '../utils/serverVersion';
|
||||
import { AuthProviderType } from './frontend-config.dto';
|
||||
import { FrontendConfigService } from './frontend-config.service';
|
||||
|
||||
/* eslint-disable
|
||||
|
@ -28,6 +27,11 @@ import { FrontendConfigService } from './frontend-config.service';
|
|||
describe('FrontendConfigService', () => {
|
||||
const domain = 'http://md.example.com';
|
||||
const emptyAuthConfig: AuthConfig = {
|
||||
common: {
|
||||
allowProfileEdits: true,
|
||||
allowChooseUsername: true,
|
||||
syncSource: undefined,
|
||||
},
|
||||
session: {
|
||||
secret: 'my-secret',
|
||||
lifetime: 1209600000,
|
||||
|
@ -37,41 +41,11 @@ describe('FrontendConfigService', () => {
|
|||
enableRegister: false,
|
||||
minimalPasswordStrength: 2,
|
||||
},
|
||||
github: {
|
||||
clientID: undefined,
|
||||
clientSecret: undefined,
|
||||
},
|
||||
google: {
|
||||
clientID: undefined,
|
||||
clientSecret: undefined,
|
||||
apiKey: undefined,
|
||||
},
|
||||
gitlab: [],
|
||||
ldap: [],
|
||||
saml: [],
|
||||
oauth2: [],
|
||||
oidc: [],
|
||||
};
|
||||
|
||||
describe('getAuthProviders', () => {
|
||||
const github: AuthConfig['github'] = {
|
||||
clientID: 'githubTestId',
|
||||
clientSecret: 'githubTestSecret',
|
||||
};
|
||||
const google: AuthConfig['google'] = {
|
||||
clientID: 'googleTestId',
|
||||
clientSecret: 'googleTestSecret',
|
||||
apiKey: 'googleTestKey',
|
||||
};
|
||||
const gitlab: AuthConfig['gitlab'] = [
|
||||
{
|
||||
identifier: 'gitlabTestIdentifier',
|
||||
providerName: 'gitlabTestName',
|
||||
baseURL: 'gitlabTestUrl',
|
||||
clientID: 'gitlabTestId',
|
||||
clientSecret: 'gitlabTestSecret',
|
||||
scope: GitlabScope.API,
|
||||
},
|
||||
];
|
||||
const ldap: AuthConfig['ldap'] = [
|
||||
{
|
||||
identifier: 'ldapTestIdentifier',
|
||||
|
@ -83,58 +57,28 @@ describe('FrontendConfigService', () => {
|
|||
searchFilter: 'ldapTestSearchFilter',
|
||||
searchAttributes: ['ldapTestSearchAttribute'],
|
||||
userIdField: 'ldapTestUserId',
|
||||
emailField: 'ldapEmailField',
|
||||
displayNameField: 'ldapTestDisplayName',
|
||||
profilePictureField: 'ldapTestProfilePicture',
|
||||
tlsCaCerts: ['ldapTestTlsCa'],
|
||||
},
|
||||
];
|
||||
const saml: AuthConfig['saml'] = [
|
||||
const oidc: AuthConfig['oidc'] = [
|
||||
{
|
||||
identifier: 'samlTestIdentifier',
|
||||
providerName: 'samlTestName',
|
||||
idpSsoUrl: 'samlTestUrl',
|
||||
idpCert: 'samlTestCert',
|
||||
clientCert: 'samlTestClientCert',
|
||||
issuer: 'samlTestIssuer',
|
||||
identifierFormat: 'samlTestUrl',
|
||||
disableRequestedAuthnContext: 'samlTestUrl',
|
||||
groupAttribute: 'samlTestUrl',
|
||||
requiredGroups: ['samlTestUrl'],
|
||||
externalGroups: ['samlTestUrl'],
|
||||
attribute: {
|
||||
id: 'samlTestUrl',
|
||||
username: 'samlTestUrl',
|
||||
email: 'samlTestUrl',
|
||||
},
|
||||
identifier: 'oidcTestIdentifier',
|
||||
providerName: 'oidcTestProviderName',
|
||||
issuer: 'oidcTestIssuer',
|
||||
clientID: 'oidcTestId',
|
||||
clientSecret: 'oidcTestSecret',
|
||||
scope: 'openid profile email',
|
||||
userIdField: '',
|
||||
userNameField: '',
|
||||
displayNameField: '',
|
||||
profilePictureField: '',
|
||||
emailField: '',
|
||||
},
|
||||
];
|
||||
const oauth2: AuthConfig['oauth2'] = [
|
||||
{
|
||||
identifier: 'oauth2Testidentifier',
|
||||
providerName: 'oauth2TestName',
|
||||
baseURL: 'oauth2TestUrl',
|
||||
userProfileURL: 'oauth2TestProfileUrl',
|
||||
userProfileIdAttr: 'oauth2TestProfileId',
|
||||
userProfileUsernameAttr: 'oauth2TestProfileUsername',
|
||||
userProfileDisplayNameAttr: 'oauth2TestProfileDisplay',
|
||||
userProfileEmailAttr: 'oauth2TestProfileEmail',
|
||||
tokenURL: 'oauth2TestTokenUrl',
|
||||
authorizationURL: 'oauth2TestAuthUrl',
|
||||
clientID: 'oauth2TestId',
|
||||
clientSecret: 'oauth2TestSecret',
|
||||
scope: 'oauth2TestScope',
|
||||
rolesClaim: 'oauth2TestRoles',
|
||||
accessRole: 'oauth2TestAccess',
|
||||
},
|
||||
];
|
||||
for (const authConfigConfigured of [
|
||||
github,
|
||||
google,
|
||||
gitlab,
|
||||
ldap,
|
||||
saml,
|
||||
oauth2,
|
||||
]) {
|
||||
for (const authConfigConfigured of [ldap, oidc]) {
|
||||
it(`works with ${JSON.stringify(authConfigConfigured)}`, async () => {
|
||||
const appConfig: AppConfig = {
|
||||
baseUrl: domain,
|
||||
|
@ -182,83 +126,41 @@ describe('FrontendConfigService', () => {
|
|||
}).compile();
|
||||
const service = module.get(FrontendConfigService);
|
||||
const config = await service.getFrontendConfig();
|
||||
if (authConfig.google.clientID) {
|
||||
expect(config.authProviders).toContainEqual({
|
||||
type: AuthProviderType.GOOGLE,
|
||||
});
|
||||
}
|
||||
if (authConfig.github.clientID) {
|
||||
expect(config.authProviders).toContainEqual({
|
||||
type: AuthProviderType.GITHUB,
|
||||
});
|
||||
}
|
||||
if (authConfig.local.enableLogin) {
|
||||
expect(config.authProviders).toContainEqual({
|
||||
type: AuthProviderType.LOCAL,
|
||||
type: ProviderType.LOCAL,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
config.authProviders.filter(
|
||||
(provider) => provider.type === AuthProviderType.GITLAB,
|
||||
).length,
|
||||
).toEqual(authConfig.gitlab.length);
|
||||
expect(
|
||||
config.authProviders.filter(
|
||||
(provider) => provider.type === AuthProviderType.LDAP,
|
||||
(provider) => provider.type === ProviderType.LDAP,
|
||||
).length,
|
||||
).toEqual(authConfig.ldap.length);
|
||||
expect(
|
||||
config.authProviders.filter(
|
||||
(provider) => provider.type === AuthProviderType.SAML,
|
||||
(provider) => provider.type === ProviderType.OIDC,
|
||||
).length,
|
||||
).toEqual(authConfig.saml.length);
|
||||
expect(
|
||||
config.authProviders.filter(
|
||||
(provider) => provider.type === AuthProviderType.OAUTH2,
|
||||
).length,
|
||||
).toEqual(authConfig.oauth2.length);
|
||||
if (authConfig.gitlab.length > 0) {
|
||||
expect(
|
||||
config.authProviders.find(
|
||||
(provider) => provider.type === AuthProviderType.GITLAB,
|
||||
),
|
||||
).toEqual({
|
||||
type: AuthProviderType.GITLAB,
|
||||
providerName: authConfig.gitlab[0].providerName,
|
||||
identifier: authConfig.gitlab[0].identifier,
|
||||
});
|
||||
}
|
||||
).toEqual(authConfig.oidc.length);
|
||||
if (authConfig.ldap.length > 0) {
|
||||
expect(
|
||||
config.authProviders.find(
|
||||
(provider) => provider.type === AuthProviderType.LDAP,
|
||||
(provider) => provider.type === ProviderType.LDAP,
|
||||
),
|
||||
).toEqual({
|
||||
type: AuthProviderType.LDAP,
|
||||
type: ProviderType.LDAP,
|
||||
providerName: authConfig.ldap[0].providerName,
|
||||
identifier: authConfig.ldap[0].identifier,
|
||||
});
|
||||
}
|
||||
if (authConfig.saml.length > 0) {
|
||||
if (authConfig.oidc.length > 0) {
|
||||
expect(
|
||||
config.authProviders.find(
|
||||
(provider) => provider.type === AuthProviderType.SAML,
|
||||
(provider) => provider.type === ProviderType.OIDC,
|
||||
),
|
||||
).toEqual({
|
||||
type: AuthProviderType.SAML,
|
||||
providerName: authConfig.saml[0].providerName,
|
||||
identifier: authConfig.saml[0].identifier,
|
||||
});
|
||||
}
|
||||
if (authConfig.oauth2.length > 0) {
|
||||
expect(
|
||||
config.authProviders.find(
|
||||
(provider) => provider.type === AuthProviderType.OAUTH2,
|
||||
),
|
||||
).toEqual({
|
||||
type: AuthProviderType.OAUTH2,
|
||||
providerName: authConfig.oauth2[0].providerName,
|
||||
identifier: authConfig.oauth2[0].identifier,
|
||||
type: ProviderType.OIDC,
|
||||
providerName: authConfig.oidc[0].providerName,
|
||||
identifier: authConfig.oidc[0].identifier,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -15,11 +15,11 @@ import externalServicesConfiguration, {
|
|||
ExternalServicesConfig,
|
||||
} from '../config/external-services.config';
|
||||
import noteConfiguration, { NoteConfig } from '../config/note.config';
|
||||
import { ProviderType } from '../identity/provider-type.enum';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { getServerVersionFromPackageJson } from '../utils/serverVersion';
|
||||
import {
|
||||
AuthProviderDto,
|
||||
AuthProviderType,
|
||||
BrandingDto,
|
||||
FrontendConfigDto,
|
||||
SpecialUrlsDto,
|
||||
|
@ -47,6 +47,8 @@ export class FrontendConfigService {
|
|||
return {
|
||||
guestAccess: this.noteConfig.guestAccess,
|
||||
allowRegister: this.authConfig.local.enableRegister,
|
||||
allowProfileEdits: this.authConfig.common.allowProfileEdits,
|
||||
allowChooseUsername: this.authConfig.common.allowChooseUsername,
|
||||
authProviders: this.getAuthProviders(),
|
||||
branding: this.getBranding(),
|
||||
maxDocumentLength: this.noteConfig.maxDocumentLength,
|
||||
|
@ -63,45 +65,22 @@ export class FrontendConfigService {
|
|||
const providers: AuthProviderDto[] = [];
|
||||
if (this.authConfig.local.enableLogin) {
|
||||
providers.push({
|
||||
type: AuthProviderType.LOCAL,
|
||||
type: ProviderType.LOCAL,
|
||||
});
|
||||
}
|
||||
if (this.authConfig.github.clientID) {
|
||||
providers.push({
|
||||
type: AuthProviderType.GITHUB,
|
||||
});
|
||||
}
|
||||
if (this.authConfig.google.clientID) {
|
||||
providers.push({
|
||||
type: AuthProviderType.GOOGLE,
|
||||
});
|
||||
}
|
||||
this.authConfig.gitlab.forEach((gitLabEntry) => {
|
||||
providers.push({
|
||||
type: AuthProviderType.GITLAB,
|
||||
providerName: gitLabEntry.providerName,
|
||||
identifier: gitLabEntry.identifier,
|
||||
});
|
||||
});
|
||||
this.authConfig.ldap.forEach((ldapEntry) => {
|
||||
providers.push({
|
||||
type: AuthProviderType.LDAP,
|
||||
type: ProviderType.LDAP,
|
||||
providerName: ldapEntry.providerName,
|
||||
identifier: ldapEntry.identifier,
|
||||
});
|
||||
});
|
||||
this.authConfig.oauth2.forEach((oauth2Entry) => {
|
||||
this.authConfig.oidc.forEach((openidConnectEntry) => {
|
||||
providers.push({
|
||||
type: AuthProviderType.OAUTH2,
|
||||
providerName: oauth2Entry.providerName,
|
||||
identifier: oauth2Entry.identifier,
|
||||
});
|
||||
});
|
||||
this.authConfig.saml.forEach((samlEntry) => {
|
||||
providers.push({
|
||||
type: AuthProviderType.SAML,
|
||||
providerName: samlEntry.providerName,
|
||||
identifier: samlEntry.identifier,
|
||||
type: ProviderType.OIDC,
|
||||
providerName: openidConnectEntry.providerName,
|
||||
identifier: openidConnectEntry.identifier,
|
||||
theme: openidConnectEntry.theme,
|
||||
});
|
||||
});
|
||||
return providers;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -41,21 +41,14 @@ export class Identity {
|
|||
providerType: string;
|
||||
|
||||
/**
|
||||
* The name of the provider.
|
||||
* Only set if there are multiple provider of that type (e.g. gitlab)
|
||||
* The identifier of the provider.
|
||||
* Only set if there are multiple providers of that type (e.g. OIDC)
|
||||
*/
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'text',
|
||||
})
|
||||
providerName: string | null;
|
||||
|
||||
/**
|
||||
* If the identity should be used as the sync source.
|
||||
* See [authentication doc](../../docs/content/dev/user_profiles.md) for clarification
|
||||
*/
|
||||
@Column()
|
||||
syncSource: boolean;
|
||||
providerIdentifier: string | null;
|
||||
|
||||
/**
|
||||
* When the identity was created.
|
||||
|
@ -78,15 +71,6 @@ export class Identity {
|
|||
})
|
||||
providerUserId: string | null;
|
||||
|
||||
/**
|
||||
* Token used to access the OAuth provider in the users name.
|
||||
*/
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'text',
|
||||
})
|
||||
oAuthAccessToken: string | null;
|
||||
|
||||
/**
|
||||
* The hash of the password
|
||||
* Only set when the type of the identity is local
|
||||
|
@ -100,15 +84,13 @@ export class Identity {
|
|||
public static create(
|
||||
user: User,
|
||||
providerType: ProviderType,
|
||||
syncSource: boolean,
|
||||
providerIdentifier: string | null,
|
||||
): Omit<Identity, 'id' | 'createdAt' | 'updatedAt'> {
|
||||
const newIdentity = new Identity();
|
||||
newIdentity.user = Promise.resolve(user);
|
||||
newIdentity.providerType = providerType;
|
||||
newIdentity.providerName = null;
|
||||
newIdentity.syncSource = syncSource;
|
||||
newIdentity.providerIdentifier = providerIdentifier;
|
||||
newIdentity.providerUserId = null;
|
||||
newIdentity.oAuthAccessToken = null;
|
||||
newIdentity.passwordHash = null;
|
||||
return newIdentity;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
|
@ -12,24 +11,18 @@ import { User } from '../users/user.entity';
|
|||
import { UsersModule } from '../users/users.module';
|
||||
import { Identity } from './identity.entity';
|
||||
import { IdentityService } from './identity.service';
|
||||
import { LdapAuthGuard, LdapStrategy } from './ldap/ldap.strategy';
|
||||
import { LocalAuthGuard, LocalStrategy } from './local/local.strategy';
|
||||
import { LdapService } from './ldap/ldap.service';
|
||||
import { LocalService } from './local/local.service';
|
||||
import { OidcService } from './oidc/oidc.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Identity, User]),
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
LoggerModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
IdentityService,
|
||||
LocalStrategy,
|
||||
LdapStrategy,
|
||||
LdapAuthGuard,
|
||||
LocalAuthGuard,
|
||||
],
|
||||
exports: [IdentityService, LocalStrategy, LdapStrategy],
|
||||
providers: [IdentityService, LdapService, LocalService, OidcService],
|
||||
exports: [IdentityService, LdapService, LocalService, OidcService],
|
||||
})
|
||||
export class IdentityModule {}
|
||||
|
|
|
@ -1,139 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import appConfigMock from '../config/mock/app.config.mock';
|
||||
import authConfigMock from '../config/mock/auth.config.mock';
|
||||
import {
|
||||
InvalidCredentialsError,
|
||||
NoLocalIdentityError,
|
||||
PasswordTooWeakError,
|
||||
} from '../errors/errors';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { User } from '../users/user.entity';
|
||||
import { checkPassword, hashPassword } from '../utils/password';
|
||||
import { Identity } from './identity.entity';
|
||||
import { IdentityService } from './identity.service';
|
||||
import { ProviderType } from './provider-type.enum';
|
||||
|
||||
describe('IdentityService', () => {
|
||||
let service: IdentityService;
|
||||
let user: User;
|
||||
let identityRepo: Repository<Identity>;
|
||||
const password = 'AStrongPasswordToStartWith123';
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
IdentityService,
|
||||
{
|
||||
provide: getRepositoryToken(Identity),
|
||||
useClass: Repository,
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfigMock, authConfigMock],
|
||||
}),
|
||||
LoggerModule,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<IdentityService>(IdentityService);
|
||||
user = User.create('test', 'Testy') as User;
|
||||
identityRepo = module.get<Repository<Identity>>(
|
||||
getRepositoryToken(Identity),
|
||||
);
|
||||
});
|
||||
|
||||
describe('createLocalIdentity', () => {
|
||||
it('works', async () => {
|
||||
jest
|
||||
.spyOn(identityRepo, 'save')
|
||||
.mockImplementationOnce(
|
||||
async (identity: Identity): Promise<Identity> => identity,
|
||||
);
|
||||
const identity = await service.createLocalIdentity(user, password);
|
||||
await checkPassword(password, identity.passwordHash ?? '').then(
|
||||
(result) => expect(result).toBeTruthy(),
|
||||
);
|
||||
expect(await identity.user).toEqual(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLocalPassword', () => {
|
||||
beforeEach(async () => {
|
||||
jest
|
||||
.spyOn(identityRepo, 'save')
|
||||
.mockImplementationOnce(
|
||||
async (identity: Identity): Promise<Identity> => identity,
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
async (identity: Identity): Promise<Identity> => identity,
|
||||
);
|
||||
const identity = await service.createLocalIdentity(user, password);
|
||||
user.identities = Promise.resolve([identity]);
|
||||
});
|
||||
it('works', async () => {
|
||||
const newPassword = 'ThisIsAStrongNewP@ssw0rd';
|
||||
const identity = await service.updateLocalPassword(user, newPassword);
|
||||
await checkPassword(newPassword, identity.passwordHash ?? '').then(
|
||||
(result) => expect(result).toBeTruthy(),
|
||||
);
|
||||
expect(await identity.user).toEqual(user);
|
||||
});
|
||||
it('fails, when user has no local identity', async () => {
|
||||
user.identities = Promise.resolve([]);
|
||||
await expect(service.updateLocalPassword(user, password)).rejects.toThrow(
|
||||
NoLocalIdentityError,
|
||||
);
|
||||
});
|
||||
it('fails, when new password is too weak', async () => {
|
||||
await expect(
|
||||
service.updateLocalPassword(user, 'password1'),
|
||||
).rejects.toThrow(PasswordTooWeakError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginWithLocalIdentity', () => {
|
||||
it('works', async () => {
|
||||
const identity = Identity.create(
|
||||
user,
|
||||
ProviderType.LOCAL,
|
||||
false,
|
||||
) as Identity;
|
||||
identity.passwordHash = await hashPassword(password);
|
||||
user.identities = Promise.resolve([identity]);
|
||||
await expect(service.checkLocalPassword(user, password)).resolves.toEqual(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
describe('fails', () => {
|
||||
it('when the password is wrong', async () => {
|
||||
const identity = Identity.create(
|
||||
user,
|
||||
ProviderType.LOCAL,
|
||||
false,
|
||||
) as Identity;
|
||||
identity.passwordHash = await hashPassword(password);
|
||||
user.identities = Promise.resolve([identity]);
|
||||
await expect(
|
||||
service.checkLocalPassword(user, 'wrong_password'),
|
||||
).rejects.toThrow(InvalidCredentialsError);
|
||||
});
|
||||
it('when user has no local identity', async () => {
|
||||
user.identities = Promise.resolve([]);
|
||||
await expect(
|
||||
service.checkLocalPassword(user, password),
|
||||
).rejects.toThrow(NoLocalIdentityError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -3,57 +3,47 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import {
|
||||
OptionsGraph,
|
||||
OptionsType,
|
||||
zxcvbnAsync,
|
||||
zxcvbnOptions,
|
||||
} from '@zxcvbn-ts/core';
|
||||
import {
|
||||
adjacencyGraphs,
|
||||
dictionary as zxcvbnCommonDictionary,
|
||||
} from '@zxcvbn-ts/language-common';
|
||||
import {
|
||||
dictionary as zxcvbnEnDictionary,
|
||||
translations as zxcvbnEnTranslations,
|
||||
} from '@zxcvbn-ts/language-en';
|
||||
import { Repository } from 'typeorm';
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import authConfiguration, { AuthConfig } from '../config/auth.config';
|
||||
import {
|
||||
InvalidCredentialsError,
|
||||
NoLocalIdentityError,
|
||||
NotInDBError,
|
||||
PasswordTooWeakError,
|
||||
} from '../errors/errors';
|
||||
import AuthConfiguration, { AuthConfig } from '../config/auth.config';
|
||||
import { NotInDBError } from '../errors/errors';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { FullUserInfoDto } from '../users/user-info.dto';
|
||||
import { User } from '../users/user.entity';
|
||||
import { checkPassword, hashPassword } from '../utils/password';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { Identity } from './identity.entity';
|
||||
import { PendingUserConfirmationDto } from './pending-user-confirmation.dto';
|
||||
import { ProviderType } from './provider-type.enum';
|
||||
import { getFirstIdentityFromUser } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class IdentityService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private usersService: UsersService,
|
||||
@InjectDataSource()
|
||||
private dataSource: DataSource,
|
||||
@Inject(AuthConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
@InjectRepository(Identity)
|
||||
private identityRepository: Repository<Identity>,
|
||||
@Inject(authConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
) {
|
||||
this.logger.setContext(IdentityService.name);
|
||||
const options: OptionsType = {
|
||||
dictionary: {
|
||||
...zxcvbnCommonDictionary,
|
||||
...zxcvbnEnDictionary,
|
||||
},
|
||||
graphs: adjacencyGraphs as OptionsGraph,
|
||||
translations: zxcvbnEnTranslations,
|
||||
};
|
||||
zxcvbnOptions.setOptions(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the identity should be updated
|
||||
*
|
||||
* @param authProviderIdentifier The identifier of the auth source
|
||||
* @return true if the authProviderIdentifier is the sync source, false otherwise
|
||||
*/
|
||||
mayUpdateIdentity(authProviderIdentifier: string): boolean {
|
||||
return this.authConfig.common.syncSource === authProviderIdentifier;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -61,15 +51,18 @@ export class IdentityService {
|
|||
* Retrieve an identity by userId and providerType.
|
||||
* @param {string} userId - the userId of the wanted identity
|
||||
* @param {ProviderType} providerType - the providerType of the wanted identity
|
||||
* @param {string} providerIdentifier - optional name of the provider if multiple exist
|
||||
*/
|
||||
async getIdentityFromUserIdAndProviderType(
|
||||
userId: string,
|
||||
providerType: ProviderType,
|
||||
providerIdentifier?: string,
|
||||
): Promise<Identity> {
|
||||
const identity = await this.identityRepository.findOne({
|
||||
where: {
|
||||
providerUserId: userId,
|
||||
providerType: providerType,
|
||||
providerType,
|
||||
providerIdentifier,
|
||||
},
|
||||
relations: ['user'],
|
||||
});
|
||||
|
@ -79,138 +72,81 @@ export class IdentityService {
|
|||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Update the given Identity with the given information
|
||||
* @param {Identity} identity - the identity to update
|
||||
* @param {string | undefined} displayName - the displayName to update the user with
|
||||
* @param {string | undefined} email - the email to update the user with
|
||||
* @param {string | undefined} profilePicture - the profilePicture to update the user with
|
||||
*/
|
||||
async updateIdentity(
|
||||
identity: Identity,
|
||||
displayName?: string,
|
||||
email?: string,
|
||||
profilePicture?: string,
|
||||
): Promise<Identity> {
|
||||
if (identity.syncSource) {
|
||||
// The identity is the syncSource and the user should be changed accordingly
|
||||
const user = await identity.user;
|
||||
let shouldSave = false;
|
||||
if (displayName) {
|
||||
user.displayName = displayName;
|
||||
shouldSave = true;
|
||||
}
|
||||
if (email) {
|
||||
user.email = email;
|
||||
shouldSave = true;
|
||||
}
|
||||
if (profilePicture) {
|
||||
// ToDo: sync image (https://github.com/hedgedoc/hedgedoc/issues/5032)
|
||||
}
|
||||
if (shouldSave) {
|
||||
identity.user = Promise.resolve(user);
|
||||
return await this.identityRepository.save(identity);
|
||||
}
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Create a new generic identity.
|
||||
* @param {User} user - the user the identity should be added to
|
||||
* @param {ProviderType} providerType - the providerType of the identity
|
||||
* @param {string} userId - the userId the identity should have
|
||||
* @param {string} providerIdentifier - the providerIdentifier of the identity
|
||||
* @param {string} providerUserId - the userId the identity should have
|
||||
* @return {Identity} the new local identity
|
||||
*/
|
||||
async createIdentity(
|
||||
user: User,
|
||||
providerType: ProviderType,
|
||||
userId: string,
|
||||
providerIdentifier: string,
|
||||
providerUserId: string,
|
||||
): Promise<Identity> {
|
||||
const identity = Identity.create(user, providerType, false);
|
||||
identity.providerUserId = userId;
|
||||
const identity = Identity.create(user, providerType, providerIdentifier);
|
||||
identity.providerUserId = providerUserId;
|
||||
return await this.identityRepository.save(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Create a new identity for internal auth
|
||||
* @param {User} user - the user the identity should be added to
|
||||
* @param {string} password - the password the identity should have
|
||||
* @return {Identity} the new local identity
|
||||
* Creates a new user with the given user data and the session data.
|
||||
*
|
||||
* @param {FullUserInfoDto} sessionUserData The user data from the session
|
||||
* @param {PendingUserConfirmationDto} updatedUserData The updated user data from the API
|
||||
* @param {ProviderType} authProviderType The type of the auth provider
|
||||
* @param {string} authProviderIdentifier The identifier of the auth provider
|
||||
* @param {string} providerUserId The id of the user in the auth system
|
||||
*/
|
||||
async createLocalIdentity(user: User, password: string): Promise<Identity> {
|
||||
const identity = Identity.create(user, ProviderType.LOCAL, false);
|
||||
identity.passwordHash = await hashPassword(password);
|
||||
return await this.identityRepository.save(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Update the internal password of the specified the user
|
||||
* @param {User} user - the user, which identity should be updated
|
||||
* @param {string} newPassword - the new password
|
||||
* @throws {NoLocalIdentityError} the specified user has no internal identity
|
||||
* @return {Identity} the changed identity
|
||||
*/
|
||||
async updateLocalPassword(
|
||||
user: User,
|
||||
newPassword: string,
|
||||
async createUserWithIdentity(
|
||||
sessionUserData: FullUserInfoDto,
|
||||
updatedUserData: PendingUserConfirmationDto,
|
||||
authProviderType: ProviderType,
|
||||
authProviderIdentifier: string,
|
||||
providerUserId: string,
|
||||
): Promise<Identity> {
|
||||
const internalIdentity: Identity | undefined =
|
||||
await getFirstIdentityFromUser(user, ProviderType.LOCAL);
|
||||
if (internalIdentity === undefined) {
|
||||
this.logger.debug(
|
||||
`The user with the username ${user.username} does not have a internal identity.`,
|
||||
'updateLocalPassword',
|
||||
);
|
||||
throw new NoLocalIdentityError('This user has no internal identity.');
|
||||
}
|
||||
await this.checkPasswordStrength(newPassword);
|
||||
internalIdentity.passwordHash = await hashPassword(newPassword);
|
||||
return await this.identityRepository.save(internalIdentity);
|
||||
}
|
||||
const profileEditsAllowed = this.authConfig.common.allowProfileEdits;
|
||||
const chooseUsernameAllowed = this.authConfig.common.allowChooseUsername;
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Checks if the user and password combination matches
|
||||
* @param {User} user - the user to use
|
||||
* @param {string} password - the password to use
|
||||
* @throws {InvalidCredentialsError} the password and user do not match
|
||||
* @throws {NoLocalIdentityError} the specified user has no internal identity
|
||||
*/
|
||||
async checkLocalPassword(user: User, password: string): Promise<void> {
|
||||
const internalIdentity: Identity | undefined =
|
||||
await getFirstIdentityFromUser(user, ProviderType.LOCAL);
|
||||
if (internalIdentity === undefined) {
|
||||
this.logger.debug(
|
||||
`The user with the username ${user.username} does not have an internal identity.`,
|
||||
'checkLocalPassword',
|
||||
);
|
||||
throw new NoLocalIdentityError('This user has no internal identity.');
|
||||
}
|
||||
if (!(await checkPassword(password, internalIdentity.passwordHash ?? ''))) {
|
||||
this.logger.debug(
|
||||
`Password check for ${user.username} did not succeed.`,
|
||||
'checkLocalPassword',
|
||||
);
|
||||
throw new InvalidCredentialsError('Password is not correct');
|
||||
}
|
||||
}
|
||||
const username = (
|
||||
chooseUsernameAllowed
|
||||
? updatedUserData.username
|
||||
: sessionUserData.username
|
||||
) as Lowercase<string>;
|
||||
const displayName = profileEditsAllowed
|
||||
? updatedUserData.displayName
|
||||
: sessionUserData.displayName;
|
||||
const photoUrl = profileEditsAllowed
|
||||
? updatedUserData.profilePicture
|
||||
: sessionUserData.photoUrl;
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Check if the password is strong enough.
|
||||
* This check is performed against the minimalPasswordStrength of the {@link AuthConfig}.
|
||||
* @param {string} password - the password to check
|
||||
* @throws {PasswordTooWeakError} the password is too weak
|
||||
*/
|
||||
async checkPasswordStrength(password: string): Promise<void> {
|
||||
const result = await zxcvbnAsync(password);
|
||||
if (result.score < this.authConfig.local.minimalPasswordStrength) {
|
||||
throw new PasswordTooWeakError();
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.startTransaction();
|
||||
try {
|
||||
const user = await this.usersService.createUser(
|
||||
username,
|
||||
displayName,
|
||||
sessionUserData.email,
|
||||
photoUrl,
|
||||
);
|
||||
const identity = await this.createIdentity(
|
||||
user,
|
||||
authProviderType,
|
||||
authProviderIdentifier,
|
||||
providerUserId,
|
||||
);
|
||||
await queryRunner.commitTransaction();
|
||||
return identity;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Error during user creation:' + String(error),
|
||||
'createUserWithIdentity',
|
||||
);
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
191
backend/src/identity/ldap/ldap.service.ts
Normal file
191
backend/src/identity/ldap/ldap.service.ts
Normal file
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { HttpException } from '@nestjs/common/exceptions/http.exception';
|
||||
import LdapAuth from 'ldapauth-fork';
|
||||
|
||||
import authConfiguration, {
|
||||
AuthConfig,
|
||||
LDAPConfig,
|
||||
} from '../../config/auth.config';
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
import { FullUserInfoWithIdDto } from '../../users/user-info.dto';
|
||||
import { Username } from '../../utils/username';
|
||||
|
||||
const LDAP_ERROR_MAP: Record<string, string> = {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
'530': 'Not Permitted to login at this time',
|
||||
'531': 'Not permitted to logon at this workstation',
|
||||
'532': 'Password expired',
|
||||
'533': 'Account disabled',
|
||||
'534': 'Account disabled',
|
||||
'701': 'Account expired',
|
||||
'773': 'User must reset password',
|
||||
'775': 'User account locked',
|
||||
default: 'Invalid username/password',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LdapService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@Inject(authConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
) {
|
||||
logger.setContext(LdapService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to log in the user with the given credentials.
|
||||
*
|
||||
* @param ldapConfig {LDAPConfig} - the ldap config to use
|
||||
* @param username {string} - the username to log in with
|
||||
* @param password {string} - the password to log in with
|
||||
* @returns {FullUserInfoWithIdDto} - the user info of the user that logged in
|
||||
* @throws {UnauthorizedException} - the user has given us incorrect credentials
|
||||
* @throws {InternalServerErrorException} - if there are errors that we can't assign to wrong credentials
|
||||
* @private
|
||||
*/
|
||||
getUserInfoFromLdap(
|
||||
ldapConfig: LDAPConfig,
|
||||
username: string, // This is not of type Username, because LDAP server may use mixed case usernames
|
||||
password: string,
|
||||
): Promise<FullUserInfoWithIdDto> {
|
||||
return new Promise<FullUserInfoWithIdDto>((resolve, reject) => {
|
||||
const auth = new LdapAuth({
|
||||
url: ldapConfig.url,
|
||||
searchBase: ldapConfig.searchBase,
|
||||
searchFilter: ldapConfig.searchFilter,
|
||||
searchAttributes: ldapConfig.searchAttributes,
|
||||
bindDN: ldapConfig.bindDn,
|
||||
bindCredentials: ldapConfig.bindCredentials,
|
||||
tlsOptions: {
|
||||
ca: ldapConfig.tlsCaCerts,
|
||||
},
|
||||
});
|
||||
|
||||
auth.once('error', (error: string | Error) => {
|
||||
const exception = this.getLdapException(username, error);
|
||||
return reject(exception);
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
auth.on('error', () => {}); // Ignore further errors
|
||||
auth.authenticate(
|
||||
username,
|
||||
password,
|
||||
(error, userInfo: Record<string, string>) => {
|
||||
auth.close(() => {
|
||||
// We don't care about the closing
|
||||
});
|
||||
if (error) {
|
||||
const exception = this.getLdapException(username, error);
|
||||
return reject(exception);
|
||||
}
|
||||
|
||||
if (!userInfo) {
|
||||
return reject(new UnauthorizedException(LDAP_ERROR_MAP['default']));
|
||||
}
|
||||
|
||||
let email: string | undefined = undefined;
|
||||
if (userInfo['mail']) {
|
||||
if (Array.isArray(userInfo['mail'])) {
|
||||
email = userInfo['mail'][0] as string;
|
||||
} else {
|
||||
email = userInfo['mail'];
|
||||
}
|
||||
}
|
||||
|
||||
return resolve({
|
||||
email,
|
||||
username: username as Username,
|
||||
id: userInfo[ldapConfig.userIdField],
|
||||
displayName: userInfo[ldapConfig.displayNameField] ?? username,
|
||||
photoUrl: undefined, // TODO LDAP stores images as binaries,
|
||||
// we need to convert them into a data-URL or alike
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and return the correct ldap config from the list of available configs.
|
||||
* @param {string} ldapIdentifier the identifier for the ldap config to be used
|
||||
* @returns {LDAPConfig} - the ldap config with the given identifier
|
||||
* @throws {NotFoundException} - there is no ldap config with the given identifier
|
||||
* @private
|
||||
*/
|
||||
getLdapConfig(ldapIdentifier: string): LDAPConfig {
|
||||
const ldapConfig: LDAPConfig | undefined = this.authConfig.ldap.find(
|
||||
(config) => config.identifier === ldapIdentifier,
|
||||
);
|
||||
if (!ldapConfig) {
|
||||
this.logger.warn(
|
||||
`The LDAP Config '${ldapIdentifier}' was requested, but doesn't exist`,
|
||||
);
|
||||
throw new NotFoundException(`There is no ldapConfig '${ldapIdentifier}'`);
|
||||
}
|
||||
return ldapConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method transforms the ldap error codes we receive into correct errors.
|
||||
* It's very much inspired by https://github.com/vesse/passport-ldapauth/blob/b58c60000a7cc62165b112274b80c654adf59fff/lib/passport-ldapauth/strategy.js#L261
|
||||
* @returns {HttpException} - the matching HTTP exception to throw to the client
|
||||
* @throws {UnauthorizedException} if error indicates that the user is not allowed to log in
|
||||
* @throws {InternalServerErrorException} in every other case
|
||||
*/
|
||||
private getLdapException(
|
||||
username: string,
|
||||
error: Error | string,
|
||||
): HttpException {
|
||||
// Invalid credentials / user not found are not errors but login failures
|
||||
let message = '';
|
||||
if (typeof error === 'object') {
|
||||
switch (error.name) {
|
||||
case 'InvalidCredentialsError': {
|
||||
message = 'Invalid username/password';
|
||||
const ldapComment = error.message.match(
|
||||
/data ([\da-fA-F]*), v[\da-fA-F]*/,
|
||||
);
|
||||
if (ldapComment && ldapComment[1]) {
|
||||
message =
|
||||
LDAP_ERROR_MAP[ldapComment[1]] || LDAP_ERROR_MAP['default'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'NoSuchObjectError':
|
||||
message = 'Bad search base';
|
||||
break;
|
||||
case 'ConstraintViolationError':
|
||||
message = 'Bad search base';
|
||||
break;
|
||||
default:
|
||||
message = 'Invalid username/password';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (
|
||||
message !== '' ||
|
||||
(typeof error === 'string' && error.startsWith('no such user:'))
|
||||
) {
|
||||
this.logger.log(
|
||||
`User with username '${username}' could not log in. Reason: ${message}`,
|
||||
);
|
||||
return new UnauthorizedException(message);
|
||||
}
|
||||
|
||||
// Other errors are (most likely) real errors
|
||||
return new InternalServerErrorException(error);
|
||||
}
|
||||
}
|
|
@ -1,287 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard, PassportStrategy } from '@nestjs/passport';
|
||||
import { Request } from 'express';
|
||||
import LdapAuth from 'ldapauth-fork';
|
||||
import { Strategy, VerifiedCallback } from 'passport-custom';
|
||||
|
||||
import authConfiguration, {
|
||||
AuthConfig,
|
||||
LDAPConfig,
|
||||
} from '../../config/auth.config';
|
||||
import { NotInDBError } from '../../errors/errors';
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { makeUsernameLowercase } from '../../utils/username';
|
||||
import { Identity } from '../identity.entity';
|
||||
import { IdentityService } from '../identity.service';
|
||||
import { ProviderType } from '../provider-type.enum';
|
||||
import { LdapLoginDto } from './ldap-login.dto';
|
||||
|
||||
const LDAP_ERROR_MAP: Record<string, string> = {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
'530': 'Not Permitted to login at this time',
|
||||
'531': 'Not permitted to logon at this workstation',
|
||||
'532': 'Password expired',
|
||||
'533': 'Account disabled',
|
||||
'534': 'Account disabled',
|
||||
'701': 'Account expired',
|
||||
'773': 'User must reset password',
|
||||
'775': 'User account locked',
|
||||
default: 'Invalid username/password',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
};
|
||||
|
||||
interface LdapPathParameters {
|
||||
ldapIdentifier: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LdapAuthGuard extends AuthGuard('ldap') {}
|
||||
|
||||
@Injectable()
|
||||
export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@Inject(authConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
private usersService: UsersService,
|
||||
private identityService: IdentityService,
|
||||
) {
|
||||
super(
|
||||
(
|
||||
request: Request<LdapPathParameters, unknown, LdapLoginDto>,
|
||||
doneCallBack: VerifiedCallback,
|
||||
) => {
|
||||
logger.setContext(LdapStrategy.name);
|
||||
const ldapIdentifier = request.params.ldapIdentifier.toUpperCase();
|
||||
const ldapConfig = this.getLDAPConfig(ldapIdentifier);
|
||||
const username = request.body.username;
|
||||
const password = request.body.password;
|
||||
this.loginWithLDAP(ldapConfig, username, password, doneCallBack);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to log in the user with the given credentials.
|
||||
* @param ldapConfig {LDAPConfig} - the ldap config to use
|
||||
* @param username {string} - the username to login with
|
||||
* @param password {string} - the password to login with
|
||||
* @param doneCallBack {VerifiedCallback} - the callback to call if the login worked
|
||||
* @returns {void}
|
||||
* @throws {UnauthorizedException} - the user has given us incorrect credentials
|
||||
* @throws {InternalServerErrorException} - if there are errors that we can't assign to wrong credentials
|
||||
* @private
|
||||
*/
|
||||
private loginWithLDAP(
|
||||
ldapConfig: LDAPConfig,
|
||||
username: string, // This is not of type Username, because LDAP server may use mixed case usernames
|
||||
password: string,
|
||||
doneCallBack: VerifiedCallback,
|
||||
): void {
|
||||
// initialize LdapAuth lib
|
||||
const auth = new LdapAuth({
|
||||
url: ldapConfig.url,
|
||||
searchBase: ldapConfig.searchBase,
|
||||
searchFilter: ldapConfig.searchFilter,
|
||||
searchAttributes: ldapConfig.searchAttributes,
|
||||
bindDN: ldapConfig.bindDn,
|
||||
bindCredentials: ldapConfig.bindCredentials,
|
||||
tlsOptions: {
|
||||
ca: ldapConfig.tlsCaCerts,
|
||||
},
|
||||
});
|
||||
|
||||
auth.once('error', (error) => {
|
||||
throw new InternalServerErrorException(error);
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
auth.on('error', () => {}); // Ignore further errors
|
||||
auth.authenticate(
|
||||
username,
|
||||
password,
|
||||
(error, user: Record<string, string>) => {
|
||||
auth.close(() => {
|
||||
// We don't care about the closing
|
||||
});
|
||||
if (error) {
|
||||
try {
|
||||
this.handleLDAPError(username, error);
|
||||
} catch (error) {
|
||||
doneCallBack(error, null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
doneCallBack(
|
||||
new UnauthorizedException(LDAP_ERROR_MAP['default']),
|
||||
null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = user[ldapConfig.userIdField];
|
||||
try {
|
||||
this.createOrUpdateIdentity(userId, ldapConfig, user, username);
|
||||
doneCallBack(null, username);
|
||||
} catch (error) {
|
||||
doneCallBack(error, null);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private createOrUpdateIdentity(
|
||||
userId: string,
|
||||
ldapConfig: LDAPConfig,
|
||||
user: Record<string, string>,
|
||||
username: string, // This is not of type Username, because LDAP server may use mixed case usernames
|
||||
): void {
|
||||
this.identityService
|
||||
.getIdentityFromUserIdAndProviderType(userId, ProviderType.LDAP)
|
||||
.then(async (identity) => {
|
||||
await this.updateIdentity(
|
||||
identity,
|
||||
ldapConfig.displayNameField,
|
||||
ldapConfig.profilePictureField,
|
||||
user,
|
||||
);
|
||||
return;
|
||||
})
|
||||
.catch(async (error) => {
|
||||
if (error instanceof NotInDBError) {
|
||||
// The user/identity does not yet exist
|
||||
const usernameLowercase = makeUsernameLowercase(username); // This ensures ldap user can be given permission via usernames
|
||||
const newUser = await this.usersService.createUser(
|
||||
usernameLowercase,
|
||||
// if there is no displayName we use the username
|
||||
user[ldapConfig.displayNameField] ?? username,
|
||||
);
|
||||
const identity = await this.identityService.createIdentity(
|
||||
newUser,
|
||||
ProviderType.LDAP,
|
||||
userId,
|
||||
);
|
||||
await this.updateIdentity(
|
||||
identity,
|
||||
ldapConfig.displayNameField,
|
||||
ldapConfig.profilePictureField,
|
||||
user,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and return the correct ldap config from the list of available configs.
|
||||
* @param {string} ldapIdentifier- the identifier for the ldap config to be used
|
||||
* @returns {LDAPConfig} - the ldap config with the given identifier
|
||||
* @throws {BadRequestException} - there is no ldap config with the given identifier
|
||||
* @private
|
||||
*/
|
||||
private getLDAPConfig(ldapIdentifier: string): LDAPConfig {
|
||||
const ldapConfig: LDAPConfig | undefined = this.authConfig.ldap.find(
|
||||
(config) => config.identifier === ldapIdentifier,
|
||||
);
|
||||
if (!ldapConfig) {
|
||||
this.logger.warn(
|
||||
`The LDAP Config '${ldapIdentifier}' was requested, but doesn't exist`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
`There is no ldapConfig '${ldapIdentifier}'`,
|
||||
);
|
||||
}
|
||||
return ldapConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Update identity with data from the ldap user.
|
||||
* @param {Identity} identity - the identity to sync
|
||||
* @param {string} displayNameField - the field to be used as a display name
|
||||
* @param {string} profilePictureField - the field to be used as a profile picture
|
||||
* @param {Record<string, string>} user - the user object from ldap
|
||||
* @private
|
||||
*/
|
||||
private async updateIdentity(
|
||||
identity: Identity,
|
||||
displayNameField: string,
|
||||
profilePictureField: string,
|
||||
user: Record<string, string>,
|
||||
): Promise<Identity> {
|
||||
let email: string | undefined = undefined;
|
||||
if (user['mail']) {
|
||||
if (Array.isArray(user['mail'])) {
|
||||
email = user['mail'][0] as string;
|
||||
} else {
|
||||
email = user['mail'];
|
||||
}
|
||||
}
|
||||
return await this.identityService.updateIdentity(
|
||||
identity,
|
||||
user[displayNameField],
|
||||
email,
|
||||
user[profilePictureField],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method transforms the ldap error codes we receive into correct errors.
|
||||
* It's very much inspired by https://github.com/vesse/passport-ldapauth/blob/b58c60000a7cc62165b112274b80c654adf59fff/lib/passport-ldapauth/strategy.js#L261
|
||||
* @throws {UnauthorizedException} if error indicates that the user is not allowed to log in
|
||||
* @throws {InternalServerErrorException} in every other cases
|
||||
* @private
|
||||
*/
|
||||
private handleLDAPError(username: string, error: Error | string): void {
|
||||
// Invalid credentials / user not found are not errors but login failures
|
||||
let message = '';
|
||||
if (typeof error === 'object') {
|
||||
switch (error.name) {
|
||||
case 'InvalidCredentialsError': {
|
||||
message = 'Invalid username/password';
|
||||
const ldapComment = error.message.match(
|
||||
/data ([\da-fA-F]*), v[\da-fA-F]*/,
|
||||
);
|
||||
if (ldapComment && ldapComment[1]) {
|
||||
message =
|
||||
LDAP_ERROR_MAP[ldapComment[1]] || LDAP_ERROR_MAP['default'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'NoSuchObjectError':
|
||||
message = 'Bad search base';
|
||||
break;
|
||||
case 'ConstraintViolationError':
|
||||
message = 'Bad search base';
|
||||
break;
|
||||
default:
|
||||
message = 'Invalid username/password';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (message !== '') {
|
||||
this.logger.log(
|
||||
`User with username '${username}' could not log in. Reason: ${message}`,
|
||||
);
|
||||
throw new UnauthorizedException(message);
|
||||
}
|
||||
|
||||
// Other errors are (most likely) real errors
|
||||
throw new InternalServerErrorException(error);
|
||||
}
|
||||
}
|
148
backend/src/identity/local/local.service.ts
Normal file
148
backend/src/identity/local/local.service.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import {
|
||||
OptionsGraph,
|
||||
OptionsType,
|
||||
zxcvbnAsync,
|
||||
zxcvbnOptions,
|
||||
} from '@zxcvbn-ts/core';
|
||||
import {
|
||||
adjacencyGraphs,
|
||||
dictionary as zxcvbnCommonDictionary,
|
||||
} from '@zxcvbn-ts/language-common';
|
||||
import {
|
||||
dictionary as zxcvbnEnDictionary,
|
||||
translations as zxcvbnEnTranslations,
|
||||
} from '@zxcvbn-ts/language-en';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import authConfiguration, { AuthConfig } from '../../config/auth.config';
|
||||
import {
|
||||
InvalidCredentialsError,
|
||||
NoLocalIdentityError,
|
||||
PasswordTooWeakError,
|
||||
} from '../../errors/errors';
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
import { User } from '../../users/user.entity';
|
||||
import { checkPassword, hashPassword } from '../../utils/password';
|
||||
import { Identity } from '../identity.entity';
|
||||
import { IdentityService } from '../identity.service';
|
||||
import { ProviderType } from '../provider-type.enum';
|
||||
|
||||
@Injectable()
|
||||
export class LocalService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private identityService: IdentityService,
|
||||
@InjectRepository(Identity)
|
||||
private identityRepository: Repository<Identity>,
|
||||
@Inject(authConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
) {
|
||||
this.logger.setContext(LocalService.name);
|
||||
const options: OptionsType = {
|
||||
dictionary: {
|
||||
...zxcvbnCommonDictionary,
|
||||
...zxcvbnEnDictionary,
|
||||
},
|
||||
graphs: adjacencyGraphs as OptionsGraph,
|
||||
translations: zxcvbnEnTranslations,
|
||||
};
|
||||
zxcvbnOptions.setOptions(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Create a new identity for internal auth
|
||||
* @param {User} user - the user the identity should be added to
|
||||
* @param {string} password - the password the identity should have
|
||||
* @return {Identity} the new local identity
|
||||
*/
|
||||
async createLocalIdentity(user: User, password: string): Promise<Identity> {
|
||||
const identity = Identity.create(user, ProviderType.LOCAL, null);
|
||||
identity.passwordHash = await hashPassword(password);
|
||||
identity.providerUserId = user.username;
|
||||
return await this.identityRepository.save(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Update the internal password of the specified the user
|
||||
* @param {User} user - the user, which identity should be updated
|
||||
* @param {string} newPassword - the new password
|
||||
* @throws {NoLocalIdentityError} the specified user has no internal identity
|
||||
* @return {Identity} the changed identity
|
||||
*/
|
||||
async updateLocalPassword(
|
||||
user: User,
|
||||
newPassword: string,
|
||||
): Promise<Identity> {
|
||||
const internalIdentity: Identity | undefined =
|
||||
await this.identityService.getIdentityFromUserIdAndProviderType(
|
||||
user.username,
|
||||
ProviderType.LOCAL,
|
||||
);
|
||||
if (internalIdentity === undefined) {
|
||||
this.logger.debug(
|
||||
`The user with the username ${user.username} does not have a internal identity.`,
|
||||
'updateLocalPassword',
|
||||
);
|
||||
throw new NoLocalIdentityError('This user has no internal identity.');
|
||||
}
|
||||
await this.checkPasswordStrength(newPassword);
|
||||
internalIdentity.passwordHash = await hashPassword(newPassword);
|
||||
return await this.identityRepository.save(internalIdentity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Checks if the user and password combination matches
|
||||
* @param {User} user - the user to use
|
||||
* @param {string} password - the password to use
|
||||
* @throws {InvalidCredentialsError} the password and user do not match
|
||||
* @throws {NoLocalIdentityError} the specified user has no internal identity
|
||||
*/
|
||||
async checkLocalPassword(user: User, password: string): Promise<void> {
|
||||
const internalIdentity: Identity | undefined =
|
||||
await this.identityService.getIdentityFromUserIdAndProviderType(
|
||||
user.username,
|
||||
ProviderType.LOCAL,
|
||||
);
|
||||
if (internalIdentity === undefined) {
|
||||
this.logger.debug(
|
||||
`The user with the username ${user.username} does not have an internal identity.`,
|
||||
'checkLocalPassword',
|
||||
);
|
||||
throw new NoLocalIdentityError('This user has no internal identity.');
|
||||
}
|
||||
if (!(await checkPassword(password, internalIdentity.passwordHash ?? ''))) {
|
||||
this.logger.debug(
|
||||
`Password check for ${user.username} did not succeed.`,
|
||||
'checkLocalPassword',
|
||||
);
|
||||
throw new InvalidCredentialsError('Password is not correct');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Check if the password is strong and long enough.
|
||||
* This check is performed against the minimalPasswordStrength of the {@link AuthConfig}.
|
||||
* @param {string} password - the password to check
|
||||
* @throws {PasswordTooWeakError} the password is too weak
|
||||
*/
|
||||
async checkPasswordStrength(password: string): Promise<void> {
|
||||
if (password.length < 6) {
|
||||
throw new PasswordTooWeakError();
|
||||
}
|
||||
const result = await zxcvbnAsync(password);
|
||||
if (result.score < this.authConfig.local.minimalPasswordStrength) {
|
||||
throw new PasswordTooWeakError();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard, PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-local';
|
||||
|
||||
import {
|
||||
InvalidCredentialsError,
|
||||
NoLocalIdentityError,
|
||||
} from '../../errors/errors';
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
import { UserRelationEnum } from '../../users/user-relation.enum';
|
||||
import { User } from '../../users/user.entity';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { Username } from '../../utils/username';
|
||||
import { IdentityService } from '../identity.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard('local') {}
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private userService: UsersService,
|
||||
private identityService: IdentityService,
|
||||
) {
|
||||
super();
|
||||
logger.setContext(LocalStrategy.name);
|
||||
}
|
||||
|
||||
async validate(username: Username, password: string): Promise<User> {
|
||||
try {
|
||||
const user = await this.userService.getUserByUsername(username, [
|
||||
UserRelationEnum.IDENTITIES,
|
||||
]);
|
||||
await this.identityService.checkLocalPassword(user, password);
|
||||
return user;
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof InvalidCredentialsError ||
|
||||
e instanceof NoLocalIdentityError
|
||||
) {
|
||||
this.logger.log(
|
||||
`User with username '${username}' could not log in. Reason: ${e.name}`,
|
||||
);
|
||||
throw new UnauthorizedException(
|
||||
'This username and password combination is not valid.',
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
264
backend/src/identity/oidc/oidc.service.ts
Normal file
264
backend/src/identity/oidc/oidc.service.ts
Normal file
|
@ -0,0 +1,264 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Client, generators, Issuer } from 'openid-client';
|
||||
|
||||
import appConfiguration, { AppConfig } from '../../config/app.config';
|
||||
import authConfiguration, {
|
||||
AuthConfig,
|
||||
OidcConfig,
|
||||
} from '../../config/auth.config';
|
||||
import { NotInDBError } from '../../errors/errors';
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
import { FullUserInfoDto } from '../../users/user-info.dto';
|
||||
import { Identity } from '../identity.entity';
|
||||
import { IdentityService } from '../identity.service';
|
||||
import { ProviderType } from '../provider-type.enum';
|
||||
import { RequestWithSession } from '../session.guard';
|
||||
|
||||
interface OidcClientConfigEntry {
|
||||
client: Client;
|
||||
issuer: Issuer;
|
||||
redirectUri: string;
|
||||
config: OidcConfig;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
private clientConfigs: Map<string, OidcClientConfigEntry> = new Map();
|
||||
|
||||
constructor(
|
||||
private identityService: IdentityService,
|
||||
private logger: ConsoleLoggerService,
|
||||
@Inject(authConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
@Inject(appConfiguration.KEY)
|
||||
private appConfig: AppConfig,
|
||||
) {
|
||||
this.initializeAllClients();
|
||||
// TODO The previous line should be regularly called again (@nestjs/cron?).
|
||||
// If the HedgeDoc instance is running for a long time,
|
||||
// the OIDC metadata or keys might change and the client needs to be reinitialized.
|
||||
this.logger.setContext(OidcService.name);
|
||||
this.logger.debug('OIDC service initialized', 'constructor');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes clients for all OIDC configurations by fetching their metadata and storing them in the clientConfigs map.
|
||||
*/
|
||||
private initializeAllClients(): void {
|
||||
this.authConfig.oidc.forEach((oidcConfig) => {
|
||||
this.fetchClientConfig(oidcConfig)
|
||||
.then((config) => {
|
||||
this.clientConfigs.set(oidcConfig.identifier, config);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error(
|
||||
`Failed to initialize OIDC client "${oidcConfig.identifier}": ${String(error)}`,
|
||||
undefined,
|
||||
'initializeClient',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Fetches the client and its config (issuer, metadata) for the given OIDC configuration.
|
||||
*
|
||||
* @param {OidcConfig} oidcConfig The OIDC configuration to fetch the client config for
|
||||
* @returns {OidcClientConfigEntry} A promise that resolves to the client configuration.
|
||||
*/
|
||||
private async fetchClientConfig(
|
||||
oidcConfig: OidcConfig,
|
||||
): Promise<OidcClientConfigEntry> {
|
||||
const useAutodiscover = oidcConfig.authorizeUrl === undefined;
|
||||
const issuer = useAutodiscover
|
||||
? await Issuer.discover(oidcConfig.issuer)
|
||||
: new Issuer({
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
issuer: oidcConfig.issuer,
|
||||
authorization_endpoint: oidcConfig.authorizeUrl,
|
||||
token_endpoint: oidcConfig.tokenUrl,
|
||||
userinfo_endpoint: oidcConfig.userinfoUrl,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
});
|
||||
|
||||
const redirectUri = `${this.appConfig.baseUrl}/api/private/auth/oidc/${oidcConfig.identifier}/callback`;
|
||||
const client = new issuer.Client({
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
client_id: oidcConfig.clientID,
|
||||
client_secret: oidcConfig.clientSecret,
|
||||
redirect_uris: [redirectUri],
|
||||
response_types: ['code'],
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
});
|
||||
return {
|
||||
client,
|
||||
issuer,
|
||||
redirectUri,
|
||||
config: oidcConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a secure code verifier for the OIDC login.
|
||||
*
|
||||
* @returns {string} The generated code verifier.
|
||||
*/
|
||||
generateCode(): string {
|
||||
return generators.codeVerifier();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the authorization URL for the given OIDC identifier and code.
|
||||
*
|
||||
* @param {string} oidcIdentifier The identifier of the OIDC configuration
|
||||
* @param {string} code The code verifier generated for the login
|
||||
* @returns {string} The generated authorization URL
|
||||
*/
|
||||
getAuthorizationUrl(oidcIdentifier: string, code: string): string {
|
||||
const clientConfig = this.clientConfigs.get(oidcIdentifier);
|
||||
if (!clientConfig) {
|
||||
throw new NotFoundException(
|
||||
'OIDC configuration not found or initialized',
|
||||
);
|
||||
}
|
||||
const client = clientConfig.client;
|
||||
return client.authorizationUrl({
|
||||
scope: clientConfig.config.scope,
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
code_challenge: generators.codeChallenge(code),
|
||||
code_challenge_method: 'S256',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Extracts the user information from the callback and stores them in the session.
|
||||
* Afterward, the user information is returned.
|
||||
*
|
||||
* @param {string} oidcIdentifier The identifier of the OIDC configuration
|
||||
* @param {RequestWithSession} request The request containing the session
|
||||
* @returns {FullUserInfoDto} The user information extracted from the callback
|
||||
*/
|
||||
async extractUserInfoFromCallback(
|
||||
oidcIdentifier: string,
|
||||
request: RequestWithSession,
|
||||
): Promise<FullUserInfoDto> {
|
||||
const clientConfig = this.clientConfigs.get(oidcIdentifier);
|
||||
if (!clientConfig) {
|
||||
throw new NotFoundException(
|
||||
'OIDC configuration not found or initialized',
|
||||
);
|
||||
}
|
||||
const client = clientConfig.client;
|
||||
const oidcConfig = clientConfig.config;
|
||||
const params = client.callbackParams(request);
|
||||
const code = request.session.oidcLoginCode;
|
||||
const isAutodiscovered = clientConfig.config.authorizeUrl === undefined;
|
||||
const tokenSet = isAutodiscovered
|
||||
? await client.callback(clientConfig.redirectUri, params, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
code_verifier: code,
|
||||
})
|
||||
: await client.oauthCallback(clientConfig.redirectUri, params, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
code_verifier: code,
|
||||
});
|
||||
|
||||
request.session.oidcIdToken = tokenSet.id_token;
|
||||
const userInfoResponse = await client.userinfo(tokenSet);
|
||||
const userId = String(
|
||||
userInfoResponse[oidcConfig.userIdField] || userInfoResponse.sub,
|
||||
);
|
||||
const username = String(
|
||||
userInfoResponse[oidcConfig.userNameField] ||
|
||||
userInfoResponse[oidcConfig.userIdField],
|
||||
).toLowerCase() as Lowercase<string>;
|
||||
const displayName = String(userInfoResponse[oidcConfig.displayNameField]);
|
||||
const email = String(userInfoResponse[oidcConfig.emailField]);
|
||||
const photoUrl = String(userInfoResponse[oidcConfig.profilePictureField]);
|
||||
const newUserData = {
|
||||
username,
|
||||
displayName,
|
||||
photoUrl,
|
||||
email,
|
||||
};
|
||||
request.session.providerUserId = userId;
|
||||
request.session.newUserData = newUserData;
|
||||
// Cleanup: The code isn't necessary anymore
|
||||
request.session.oidcLoginCode = undefined;
|
||||
return newUserData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Checks if an identity exists for a given OIDC user and returns it if it does.
|
||||
*
|
||||
* @param {string} oidcIdentifier The identifier of the OIDC configuration
|
||||
* @param {string} oidcUserId The id of the user in the OIDC system
|
||||
* @returns {Identity} The identity if it exists
|
||||
* @returns {null} when the identity does not exist
|
||||
*/
|
||||
async getExistingOidcIdentity(
|
||||
oidcIdentifier: string,
|
||||
oidcUserId: string,
|
||||
): Promise<Identity | null> {
|
||||
const clientConfig = this.clientConfigs.get(oidcIdentifier);
|
||||
if (!clientConfig) {
|
||||
throw new NotFoundException(
|
||||
'OIDC configuration not found or initialized',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await this.identityService.getIdentityFromUserIdAndProviderType(
|
||||
oidcUserId,
|
||||
ProviderType.OIDC,
|
||||
oidcIdentifier,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof NotInDBError) {
|
||||
return null;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the logout URL for the given request if the user is logged in with OIDC.
|
||||
*
|
||||
* @param {RequestWithSession} request The request containing the session
|
||||
* @returns {string} The logout URL if the user is logged in with OIDC
|
||||
* @returns {null} when there is no logout URL to redirect to
|
||||
*/
|
||||
getLogoutUrl(request: RequestWithSession): string | null {
|
||||
const oidcIdentifier = request.session.authProviderIdentifier;
|
||||
if (!oidcIdentifier) {
|
||||
return null;
|
||||
}
|
||||
const clientConfig = this.clientConfigs.get(oidcIdentifier);
|
||||
if (!clientConfig) {
|
||||
throw new InternalServerErrorException(
|
||||
'OIDC configuration not found or initialized',
|
||||
);
|
||||
}
|
||||
const issuer = clientConfig.issuer;
|
||||
const endSessionEndpoint = issuer.metadata.end_session_endpoint;
|
||||
const idToken = request.session.oidcIdToken;
|
||||
if (!endSessionEndpoint) {
|
||||
return null;
|
||||
}
|
||||
return `${endSessionEndpoint}?post_logout_redirect_uri=${this.appConfig.baseUrl}${idToken ? `&id_token_hint=${idToken}` : ''}`;
|
||||
}
|
||||
}
|
20
backend/src/identity/pending-user-confirmation.dto.ts
Normal file
20
backend/src/identity/pending-user-confirmation.dto.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export class PendingUserConfirmationDto extends BaseDto {
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
displayName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
profilePicture: string | undefined;
|
||||
}
|
|
@ -1,15 +1,12 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export enum ProviderType {
|
||||
GUEST = 'guest',
|
||||
LOCAL = 'local',
|
||||
LDAP = 'ldap',
|
||||
SAML = 'saml',
|
||||
OAUTH2 = 'oauth2',
|
||||
GITLAB = 'gitlab',
|
||||
GITHUB = 'github',
|
||||
GOOGLE = 'google',
|
||||
OIDC = 'oidc',
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -10,13 +10,20 @@ import {
|
|||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { CompleteRequest } from '../api/utils/request.type';
|
||||
import { GuestAccess } from '../config/guest_access.enum';
|
||||
import noteConfiguration, { NoteConfig } from '../config/note.config';
|
||||
import { NotInDBError } from '../errors/errors';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { SessionState } from '../sessions/session.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { ProviderType } from './provider-type.enum';
|
||||
|
||||
export type RequestWithSession = Request & {
|
||||
session: SessionState;
|
||||
};
|
||||
|
||||
/**
|
||||
* This guard checks if a session is present.
|
||||
|
@ -42,7 +49,9 @@ export class SessionGuard implements CanActivate {
|
|||
const username = request.session?.username;
|
||||
if (!username) {
|
||||
if (this.noteConfig.guestAccess !== GuestAccess.DENY && request.session) {
|
||||
request.session.authProvider = 'guest';
|
||||
if (!request.session.authProviderType) {
|
||||
request.session.authProviderType = ProviderType.GUEST;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
this.logger.debug('The user has no session.');
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { User } from '../users/user.entity';
|
||||
import { Identity } from './identity.entity';
|
||||
import { ProviderType } from './provider-type.enum';
|
||||
|
||||
/**
|
||||
* Get the first identity of a given type from the user
|
||||
* @param {User} user - the user to get the identity from
|
||||
* @param {ProviderType} providerType - the type of the identity
|
||||
* @return {Identity | undefined} the first identity of the user or undefined, if such an identity can not be found
|
||||
*/
|
||||
export async function getFirstIdentityFromUser(
|
||||
user: User,
|
||||
providerType: ProviderType,
|
||||
): Promise<Identity | undefined> {
|
||||
const identities = await user.identities;
|
||||
if (identities === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return identities.find(
|
||||
(aIdentity) => aIdentity.providerType === (providerType as string),
|
||||
);
|
||||
}
|
|
@ -1,7 +1,12 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MariadbInit1725204784823 implements MigrationInterface {
|
||||
name = 'MariadbInit1725204784823';
|
||||
export class Init1725266569705 implements MigrationInterface {
|
||||
name = 'Init1725266569705';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
|
@ -38,7 +43,7 @@ export class MariadbInit1725204784823 implements MigrationInterface {
|
|||
`CREATE TABLE \`author\` (\`id\` int NOT NULL AUTO_INCREMENT, \`color\` int NOT NULL, \`userId\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`identity\` (\`id\` int NOT NULL AUTO_INCREMENT, \`providerType\` varchar(255) NOT NULL, \`providerName\` text NULL, \`syncSource\` tinyint NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`providerUserId\` text NULL, \`oAuthAccessToken\` text NULL, \`passwordHash\` text NULL, \`userId\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
`CREATE TABLE \`identity\` (\`id\` int NOT NULL AUTO_INCREMENT, \`providerType\` varchar(255) NOT NULL, \`providerIdentifier\` text NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`providerUserId\` text NULL, \`passwordHash\` text NULL, \`userId\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`public_auth_token\` (\`id\` int NOT NULL AUTO_INCREMENT, \`keyId\` varchar(255) NOT NULL, \`label\` varchar(255) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`hash\` varchar(255) NOT NULL, \`validUntil\` datetime NOT NULL, \`lastUsedAt\` date NULL, \`userId\` int NULL, UNIQUE INDEX \`IDX_b4c4b9179f72ef63c32248e83a\` (\`keyId\`), UNIQUE INDEX \`IDX_6450514886fa4182c889c076df\` (\`hash\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
|
@ -1,7 +1,12 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class Init1725203299761 implements MigrationInterface {
|
||||
name = 'Init1725203299761';
|
||||
export class Init1725266697932 implements MigrationInterface {
|
||||
name = 'Init1725266697932';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
|
@ -50,7 +55,7 @@ export class Init1725203299761 implements MigrationInterface {
|
|||
`CREATE TABLE "author" ("id" SERIAL NOT NULL, "color" integer NOT NULL, "userId" integer, CONSTRAINT "PK_5a0e79799d372fe56f2f3fa6871" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "identity" ("id" SERIAL NOT NULL, "providerType" character varying NOT NULL, "providerName" text, "syncSource" boolean NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "providerUserId" text, "oAuthAccessToken" text, "passwordHash" text, "userId" integer, CONSTRAINT "PK_ff16a44186b286d5e626178f726" PRIMARY KEY ("id"))`,
|
||||
`CREATE TABLE "identity" ("id" SERIAL NOT NULL, "providerType" character varying NOT NULL, "providerIdentifier" text, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "providerUserId" text, "passwordHash" text, "userId" integer, CONSTRAINT "PK_ff16a44186b286d5e626178f726" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "public_auth_token" ("id" SERIAL NOT NULL, "keyId" character varying NOT NULL, "label" character varying NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "hash" character varying NOT NULL, "validUntil" TIMESTAMP NOT NULL, "lastUsedAt" date, "userId" integer, CONSTRAINT "UQ_b4c4b9179f72ef63c32248e83ab" UNIQUE ("keyId"), CONSTRAINT "UQ_6450514886fa4182c889c076df6" UNIQUE ("hash"), CONSTRAINT "PK_1bdb7c2d237fb02d84fa75f48a5" PRIMARY KEY ("id"))`,
|
|
@ -1,7 +1,12 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SqliteInit1725204990810 implements MigrationInterface {
|
||||
name = 'SqliteInit1725204990810';
|
||||
export class Init1725268109950 implements MigrationInterface {
|
||||
name = 'Init1725268109950';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
|
@ -50,7 +55,7 @@ export class SqliteInit1725204990810 implements MigrationInterface {
|
|||
`CREATE TABLE "author" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "color" integer NOT NULL, "userId" integer)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerName" text, "syncSource" boolean NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "oAuthAccessToken" text, "passwordHash" text, "userId" integer)`,
|
||||
`CREATE TABLE "identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerIdentifier" text, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "passwordHash" text, "userId" integer)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "public_auth_token" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "keyId" varchar NOT NULL, "label" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "hash" varchar NOT NULL, "validUntil" datetime NOT NULL, "lastUsedAt" date, "userId" integer, CONSTRAINT "UQ_b4c4b9179f72ef63c32248e83ab" UNIQUE ("keyId"), CONSTRAINT "UQ_6450514886fa4182c889c076df6" UNIQUE ("hash"))`,
|
||||
|
@ -199,10 +204,10 @@ export class SqliteInit1725204990810 implements MigrationInterface {
|
|||
`ALTER TABLE "temporary_author" RENAME TO "author"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerName" text, "syncSource" boolean NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "oAuthAccessToken" text, "passwordHash" text, "userId" integer, CONSTRAINT "FK_12915039d2868ab654567bf5181" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`,
|
||||
`CREATE TABLE "temporary_identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerIdentifier" text, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "passwordHash" text, "userId" integer, CONSTRAINT "FK_12915039d2868ab654567bf5181" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_identity"("id", "providerType", "providerName", "syncSource", "createdAt", "updatedAt", "providerUserId", "oAuthAccessToken", "passwordHash", "userId") SELECT "id", "providerType", "providerName", "syncSource", "createdAt", "updatedAt", "providerUserId", "oAuthAccessToken", "passwordHash", "userId" FROM "identity"`,
|
||||
`INSERT INTO "temporary_identity"("id", "providerType", "providerIdentifier", "createdAt", "updatedAt", "providerUserId", "passwordHash", "userId") SELECT "id", "providerType", "providerIdentifier", "createdAt", "updatedAt", "providerUserId", "passwordHash", "userId" FROM "identity"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "identity"`);
|
||||
await queryRunner.query(
|
||||
|
@ -343,10 +348,10 @@ export class SqliteInit1725204990810 implements MigrationInterface {
|
|||
`ALTER TABLE "identity" RENAME TO "temporary_identity"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerName" text, "syncSource" boolean NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "oAuthAccessToken" text, "passwordHash" text, "userId" integer)`,
|
||||
`CREATE TABLE "identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerIdentifier" text, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "passwordHash" text, "userId" integer)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "identity"("id", "providerType", "providerName", "syncSource", "createdAt", "updatedAt", "providerUserId", "oAuthAccessToken", "passwordHash", "userId") SELECT "id", "providerType", "providerName", "syncSource", "createdAt", "updatedAt", "providerUserId", "oAuthAccessToken", "passwordHash", "userId" FROM "temporary_identity"`,
|
||||
`INSERT INTO "identity"("id", "providerType", "providerIdentifier", "createdAt", "updatedAt", "providerUserId", "passwordHash", "userId") SELECT "id", "providerType", "providerIdentifier", "createdAt", "updatedAt", "providerUserId", "passwordHash", "userId" FROM "temporary_identity"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_identity"`);
|
||||
await queryRunner.query(
|
|
@ -11,6 +11,7 @@ import crypto from 'crypto';
|
|||
import { Repository } from 'typeorm';
|
||||
|
||||
import appConfigMock from '../config/mock/app.config.mock';
|
||||
import authConfigMock from '../config/mock/auth.config.mock';
|
||||
import {
|
||||
NotInDBError,
|
||||
TokenNotValidError,
|
||||
|
@ -54,7 +55,7 @@ describe('AuthService', () => {
|
|||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfigMock],
|
||||
load: [appConfigMock, authConfigMock],
|
||||
}),
|
||||
PassportModule,
|
||||
UsersModule,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -69,7 +69,7 @@ dataSource
|
|||
Author.create(1),
|
||||
)) as Author;
|
||||
const user = (await dataSource.manager.save(users[i])) as User;
|
||||
const identity = Identity.create(user, ProviderType.LOCAL, false);
|
||||
const identity = Identity.create(user, ProviderType.LOCAL, null);
|
||||
identity.passwordHash = await hashPassword(password);
|
||||
dataSource.manager.create(Identity, identity);
|
||||
author.user = dataSource.manager.save(user);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -17,15 +17,37 @@ import { DatabaseType } from '../config/database-type.enum';
|
|||
import databaseConfiguration, {
|
||||
DatabaseConfig,
|
||||
} from '../config/database.config';
|
||||
import { ProviderType } from '../identity/provider-type.enum';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { FullUserInfoDto } from '../users/user-info.dto';
|
||||
import { HEDGEDOC_SESSION } from '../utils/session';
|
||||
import { Username } from '../utils/username';
|
||||
import { Session } from './session.entity';
|
||||
|
||||
export interface SessionState {
|
||||
/** Details about the currently used session cookie */
|
||||
cookie: unknown;
|
||||
|
||||
/** Contains the username if logged in completely, is undefined when not being logged in */
|
||||
username?: Username;
|
||||
authProvider: string;
|
||||
|
||||
/** The auth provider that is used for the current login or pending login */
|
||||
authProviderType?: ProviderType;
|
||||
|
||||
/** The identifier of the auth provider that is used for the current login or pending login */
|
||||
authProviderIdentifier?: string;
|
||||
|
||||
/** The id token to identify a user session with an OIDC auth provider, required for the logout */
|
||||
oidcIdToken?: string;
|
||||
|
||||
/** The (random) OIDC code for verifying that OIDC responses match the OIDC requests */
|
||||
oidcLoginCode?: string;
|
||||
|
||||
/** The user id as provided from the external auth provider, required for matching to a HedgeDoc identity */
|
||||
providerUserId?: string;
|
||||
|
||||
/** The user data of the user that is currently being created */
|
||||
newUserData?: FullUserInfoDto;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsLowercase, IsString } from 'class-validator';
|
||||
import { IsLowercase, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
import { Username } from '../utils/username';
|
||||
|
@ -33,11 +33,12 @@ export class UserInfoDto extends BaseDto {
|
|||
* URL of the profile picture
|
||||
* @example "https://hedgedoc.example.com/uploads/johnsmith.png"
|
||||
*/
|
||||
@ApiProperty({
|
||||
@ApiPropertyOptional({
|
||||
format: 'uri',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
photoUrl: string;
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -49,11 +50,21 @@ export class FullUserInfoDto extends UserInfoDto {
|
|||
* Email address of the user
|
||||
* @example "john.smith@example.com"
|
||||
*/
|
||||
@ApiProperty({
|
||||
@ApiPropertyOptional({
|
||||
format: 'email',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export class FullUserInfoWithIdDto extends FullUserInfoDto {
|
||||
/**
|
||||
* The user's ID
|
||||
* @example 42
|
||||
*/
|
||||
@IsString()
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class UserLoginInfoDto extends UserInfoDto {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -80,12 +80,14 @@ export class User {
|
|||
public static create(
|
||||
username: Username,
|
||||
displayName: string,
|
||||
email?: string,
|
||||
photoUrl?: string,
|
||||
): Omit<User, 'id' | 'createdAt' | 'updatedAt'> {
|
||||
const newUser = new User();
|
||||
newUser.username = username;
|
||||
newUser.displayName = displayName;
|
||||
newUser.photo = null;
|
||||
newUser.email = null;
|
||||
newUser.photo = photoUrl ?? null;
|
||||
newUser.email = email ?? null;
|
||||
newUser.ownedNotes = Promise.resolve([]);
|
||||
newUser.publicAuthTokens = Promise.resolve([]);
|
||||
newUser.identities = Promise.resolve([]);
|
||||
|
|
21
backend/src/users/username-check.dto.ts
Normal file
21
backend/src/users/username-check.dto.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { IsBoolean, IsLowercase, IsString } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
import { Username } from '../utils/username';
|
||||
|
||||
export class UsernameCheckDto extends BaseDto {
|
||||
// eslint-disable-next-line @darraghor/nestjs-typed/validated-non-primitive-property-needs-type-decorator
|
||||
@IsString()
|
||||
@IsLowercase()
|
||||
username: Username;
|
||||
}
|
||||
|
||||
export class UsernameCheckResponseDto extends BaseDto {
|
||||
@IsBoolean()
|
||||
usernameAvailable: boolean;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -9,6 +9,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
|||
import { Repository } from 'typeorm';
|
||||
|
||||
import appConfigMock from '../config/mock/app.config.mock';
|
||||
import authConfigMock from '../config/mock/auth.config.mock';
|
||||
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { User } from './user.entity';
|
||||
|
@ -30,7 +31,7 @@ describe('UsersService', () => {
|
|||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfigMock],
|
||||
load: [appConfigMock, authConfigMock],
|
||||
}),
|
||||
LoggerModule,
|
||||
],
|
||||
|
@ -100,7 +101,7 @@ describe('UsersService', () => {
|
|||
return user;
|
||||
},
|
||||
);
|
||||
await service.changeDisplayName(user, newDisplayName);
|
||||
await service.updateUser(user, newDisplayName, undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { REGEX_USERNAME } from '@hedgedoc/commons';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import AuthConfiguration, { AuthConfig } from '../config/auth.config';
|
||||
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { Username } from '../utils/username';
|
||||
|
@ -22,6 +24,8 @@ import { User } from './user.entity';
|
|||
export class UsersService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@Inject(AuthConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
@InjectRepository(User) private userRepository: Repository<User>,
|
||||
) {
|
||||
this.logger.setContext(UsersService.name);
|
||||
|
@ -32,11 +36,24 @@ export class UsersService {
|
|||
* Create a new user with a given username and displayName
|
||||
* @param {Username} username - the username the new user shall have
|
||||
* @param {string} displayName - the display name the new user shall have
|
||||
* @param {string} [email] - the email the new user shall have
|
||||
* @param {string} [photoUrl] - the photoUrl the new user shall have
|
||||
* @return {User} the user
|
||||
* @throws {BadRequestException} if the username contains invalid characters or is too short
|
||||
* @throws {AlreadyInDBError} the username is already taken.
|
||||
*/
|
||||
async createUser(username: Username, displayName: string): Promise<User> {
|
||||
const user = User.create(username, displayName);
|
||||
async createUser(
|
||||
username: Username,
|
||||
displayName: string,
|
||||
email?: string,
|
||||
photoUrl?: string,
|
||||
): Promise<User> {
|
||||
if (!REGEX_USERNAME.test(username)) {
|
||||
throw new BadRequestException(
|
||||
`The username '${username}' is not a valid username.`,
|
||||
);
|
||||
}
|
||||
const user = User.create(username, displayName, email, photoUrl);
|
||||
try {
|
||||
return await this.userRepository.save(user);
|
||||
} catch {
|
||||
|
@ -66,13 +83,51 @@ export class UsersService {
|
|||
|
||||
/**
|
||||
* @async
|
||||
* Change the displayName of the specified user
|
||||
* @param {User} user - the user to be changed
|
||||
* @param displayName - the new displayName
|
||||
* Update the given User with the given information.
|
||||
* Use {@code null} to clear the stored value (email or profilePicture).
|
||||
* Use {@code undefined} to keep the stored value.
|
||||
* @param {User} user - the User to update
|
||||
* @param {string | undefined} displayName - the displayName to update the user with
|
||||
* @param {string | null | undefined} email - the email to update the user with
|
||||
* @param {string | null | undefined} profilePicture - the profilePicture to update the user with
|
||||
*/
|
||||
async changeDisplayName(user: User, displayName: string): Promise<void> {
|
||||
user.displayName = displayName;
|
||||
await this.userRepository.save(user);
|
||||
async updateUser(
|
||||
user: User,
|
||||
displayName?: string,
|
||||
email?: string | null,
|
||||
profilePicture?: string | null,
|
||||
): Promise<User> {
|
||||
let shouldSave = false;
|
||||
if (displayName !== undefined) {
|
||||
user.displayName = displayName;
|
||||
shouldSave = true;
|
||||
}
|
||||
if (email !== undefined) {
|
||||
user.email = email;
|
||||
shouldSave = true;
|
||||
}
|
||||
if (profilePicture !== undefined) {
|
||||
user.photo = profilePicture;
|
||||
shouldSave = true;
|
||||
// ToDo: handle LDAP images (https://github.com/hedgedoc/hedgedoc/issues/5032)
|
||||
}
|
||||
if (shouldSave) {
|
||||
return await this.userRepository.save(user);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Checks if the user with the specified username exists
|
||||
* @param username - the username to check
|
||||
* @return {boolean} true if the user exists, false otherwise
|
||||
*/
|
||||
async checkIfUserExists(username: Username): Promise<boolean> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { username: username },
|
||||
});
|
||||
return user !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
@ -27,7 +27,8 @@ export function setupSessionMiddleware(
|
|||
name: HEDGEDOC_SESSION,
|
||||
secret: authConfig.session.secret,
|
||||
cookie: {
|
||||
maxAge: authConfig.session.lifetime,
|
||||
// Handle session duration in seconds instead of ms
|
||||
maxAge: authConfig.session.lifetime * 1000,
|
||||
},
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue