mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-24 18:56:32 -05:00
feat(auth): refactor auth, add oidc
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
|
@ -64,6 +64,7 @@
|
||||||
"minio": "7.1.3",
|
"minio": "7.1.3",
|
||||||
"mysql": "2.18.1",
|
"mysql": "2.18.1",
|
||||||
"node-fetch": "2.7.0",
|
"node-fetch": "2.7.0",
|
||||||
|
"openid-client": "5.6.5",
|
||||||
"passport": "0.7.0",
|
"passport": "0.7.0",
|
||||||
"passport-custom": "1.1.1",
|
"passport-custom": "1.1.1",
|
||||||
"passport-http-bearer": "1.0.1",
|
"passport-http-bearer": "1.0.1",
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -8,123 +8,106 @@ import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Param,
|
Get,
|
||||||
Post,
|
|
||||||
Put,
|
Put,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { Session } from 'express-session';
|
|
||||||
|
|
||||||
import { IdentityService } from '../../../identity/identity.service';
|
import { IdentityService } from '../../../identity/identity.service';
|
||||||
import { LdapLoginDto } from '../../../identity/ldap/ldap-login.dto';
|
import { OidcService } from '../../../identity/oidc/oidc.service';
|
||||||
import { LdapAuthGuard } from '../../../identity/ldap/ldap.strategy';
|
import { PendingUserConfirmationDto } from '../../../identity/pending-user-confirmation.dto';
|
||||||
import { LocalAuthGuard } from '../../../identity/local/local.strategy';
|
import { ProviderType } from '../../../identity/provider-type.enum';
|
||||||
import { LoginDto } from '../../../identity/local/login.dto';
|
import {
|
||||||
import { RegisterDto } from '../../../identity/local/register.dto';
|
RequestWithSession,
|
||||||
import { UpdatePasswordDto } from '../../../identity/local/update-password.dto';
|
SessionGuard,
|
||||||
import { SessionGuard } from '../../../identity/session.guard';
|
} from '../../../identity/session.guard';
|
||||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||||
import { SessionState } from '../../../sessions/session.service';
|
import { FullUserInfoDto } from '../../../users/user-info.dto';
|
||||||
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 { OpenApi } from '../../utils/openapi.decorator';
|
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')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: ConsoleLoggerService,
|
private readonly logger: ConsoleLoggerService,
|
||||||
private usersService: UsersService,
|
|
||||||
private identityService: IdentityService,
|
private identityService: IdentityService,
|
||||||
|
private oidcService: OidcService,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(AuthController.name);
|
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)
|
@UseGuards(SessionGuard)
|
||||||
@Delete('logout')
|
@Delete('logout')
|
||||||
@OpenApi(204, 400, 401)
|
@OpenApi(200, 400, 401)
|
||||||
logout(@Req() request: Request & { session: Session }): Promise<void> {
|
logout(@Req() request: RequestWithSession): { redirect: string } {
|
||||||
return new Promise((resolve, reject) => {
|
let logoutUrl: string | null = null;
|
||||||
|
if (request.session.authProviderType === ProviderType.OIDC) {
|
||||||
|
logoutUrl = this.oidcService.getLogoutUrl(request);
|
||||||
|
}
|
||||||
request.session.destroy((err) => {
|
request.session.destroy((err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
this.logger.error('Encountered an error while logging out: ${err}');
|
this.logger.error(
|
||||||
reject(new BadRequestException('Unable to log out'));
|
'Error during logout:' + String(err),
|
||||||
} else {
|
undefined,
|
||||||
resolve();
|
'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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -75,6 +75,11 @@ export class MeController {
|
||||||
@RequestUser() user: User,
|
@RequestUser() user: User,
|
||||||
@Body('displayName') newDisplayName: string,
|
@Body('displayName') newDisplayName: string,
|
||||||
): Promise<void> {
|
): 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -18,6 +18,9 @@ import { RevisionsModule } from '../../revisions/revisions.module';
|
||||||
import { UsersModule } from '../../users/users.module';
|
import { UsersModule } from '../../users/users.module';
|
||||||
import { AliasController } from './alias/alias.controller';
|
import { AliasController } from './alias/alias.controller';
|
||||||
import { AuthController } from './auth/auth.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 { ConfigController } from './config/config.controller';
|
||||||
import { GroupsController } from './groups/groups.controller';
|
import { GroupsController } from './groups/groups.controller';
|
||||||
import { HistoryController } from './me/history/history.controller';
|
import { HistoryController } from './me/history/history.controller';
|
||||||
|
@ -52,6 +55,9 @@ import { UsersController } from './users/users.controller';
|
||||||
AuthController,
|
AuthController,
|
||||||
UsersController,
|
UsersController,
|
||||||
GroupsController,
|
GroupsController,
|
||||||
|
LdapController,
|
||||||
|
LocalController,
|
||||||
|
OidcController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PrivateApiModule {}
|
export class PrivateApiModule {}
|
||||||
|
|
|
@ -3,11 +3,15 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 { ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||||
import { UserInfoDto } from '../../../users/user-info.dto';
|
import { UserInfoDto } from '../../../users/user-info.dto';
|
||||||
|
import {
|
||||||
|
UsernameCheckDto,
|
||||||
|
UsernameCheckResponseDto,
|
||||||
|
} from '../../../users/username-check.dto';
|
||||||
import { UsersService } from '../../../users/users.service';
|
import { UsersService } from '../../../users/users.service';
|
||||||
import { Username } from '../../../utils/username';
|
import { Username } from '../../../utils/username';
|
||||||
import { OpenApi } from '../../utils/openapi.decorator';
|
import { OpenApi } from '../../utils/openapi.decorator';
|
||||||
|
@ -22,7 +26,20 @@ export class UsersController {
|
||||||
this.logger.setContext(UsersController.name);
|
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)
|
@OpenApi(200)
|
||||||
async getUser(@Param('username') username: Username): Promise<UserInfoDto> {
|
async getUser(@Param('username') username: Username): Promise<UserInfoDto> {
|
||||||
return this.userService.toUserDto(
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -20,12 +20,12 @@ import { CompleteRequest } from './request.type';
|
||||||
export const SessionAuthProvider = createParamDecorator(
|
export const SessionAuthProvider = createParamDecorator(
|
||||||
(data: unknown, ctx: ExecutionContext) => {
|
(data: unknown, ctx: ExecutionContext) => {
|
||||||
const request: CompleteRequest = ctx.switchToHttp().getRequest();
|
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
|
// We should have an auth provider here, otherwise something is wrong
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
'Session is missing an auth provider identifier',
|
'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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import mockedEnv from 'mocked-env';
|
import mockedEnv from 'mocked-env';
|
||||||
|
|
||||||
import authConfig from './auth.config';
|
import authConfig from './auth.config';
|
||||||
|
import { Theme } from './theme.enum';
|
||||||
|
|
||||||
describe('authConfig', () => {
|
describe('authConfig', () => {
|
||||||
const secret = 'this-is-a-secret';
|
const secret = 'this-is-a-secret';
|
||||||
|
@ -162,6 +163,7 @@ describe('authConfig', () => {
|
||||||
const searchAttributes = ['mail', 'uid'];
|
const searchAttributes = ['mail', 'uid'];
|
||||||
const userIdField = 'non_default_uid';
|
const userIdField = 'non_default_uid';
|
||||||
const displayNameField = 'non_default_display_name';
|
const displayNameField = 'non_default_display_name';
|
||||||
|
const emailField = 'non_default_email';
|
||||||
const profilePictureField = 'non_default_profile_picture';
|
const profilePictureField = 'non_default_profile_picture';
|
||||||
const bindDn = 'cn=admin,dc=planetexpress,dc=com';
|
const bindDn = 'cn=admin,dc=planetexpress,dc=com';
|
||||||
const bindCredentials = 'GoodNewsEveryone';
|
const bindCredentials = 'GoodNewsEveryone';
|
||||||
|
@ -176,6 +178,7 @@ describe('authConfig', () => {
|
||||||
HD_AUTH_LDAP_FUTURAMA_SEARCH_FILTER: searchFilter,
|
HD_AUTH_LDAP_FUTURAMA_SEARCH_FILTER: searchFilter,
|
||||||
HD_AUTH_LDAP_FUTURAMA_SEARCH_ATTRIBUTES: searchAttributes.join(','),
|
HD_AUTH_LDAP_FUTURAMA_SEARCH_ATTRIBUTES: searchAttributes.join(','),
|
||||||
HD_AUTH_LDAP_FUTURAMA_USER_ID_FIELD: userIdField,
|
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_DISPLAY_NAME_FIELD: displayNameField,
|
||||||
HD_AUTH_LDAP_FUTURAMA_PROFILE_PICTURE_FIELD: profilePictureField,
|
HD_AUTH_LDAP_FUTURAMA_PROFILE_PICTURE_FIELD: profilePictureField,
|
||||||
HD_AUTH_LDAP_FUTURAMA_BIND_DN: bindDn,
|
HD_AUTH_LDAP_FUTURAMA_BIND_DN: bindDn,
|
||||||
|
@ -199,7 +202,7 @@ describe('authConfig', () => {
|
||||||
const config = authConfig();
|
const config = authConfig();
|
||||||
expect(config.ldap).toHaveLength(1);
|
expect(config.ldap).toHaveLength(1);
|
||||||
const firstLdap = config.ldap[0];
|
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.url).toEqual(url);
|
||||||
expect(firstLdap.providerName).toEqual(providerName);
|
expect(firstLdap.providerName).toEqual(providerName);
|
||||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||||
|
@ -207,6 +210,7 @@ describe('authConfig', () => {
|
||||||
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
||||||
expect(firstLdap.userIdField).toEqual(userIdField);
|
expect(firstLdap.userIdField).toEqual(userIdField);
|
||||||
expect(firstLdap.displayNameField).toEqual(displayNameField);
|
expect(firstLdap.displayNameField).toEqual(displayNameField);
|
||||||
|
expect(firstLdap.emailField).toEqual(emailField);
|
||||||
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
|
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
|
||||||
expect(firstLdap.bindDn).toEqual(bindDn);
|
expect(firstLdap.bindDn).toEqual(bindDn);
|
||||||
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
|
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
|
||||||
|
@ -230,7 +234,7 @@ describe('authConfig', () => {
|
||||||
const config = authConfig();
|
const config = authConfig();
|
||||||
expect(config.ldap).toHaveLength(1);
|
expect(config.ldap).toHaveLength(1);
|
||||||
const firstLdap = config.ldap[0];
|
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.url).toEqual(url);
|
||||||
expect(firstLdap.providerName).toEqual('LDAP');
|
expect(firstLdap.providerName).toEqual('LDAP');
|
||||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||||
|
@ -261,7 +265,7 @@ describe('authConfig', () => {
|
||||||
const config = authConfig();
|
const config = authConfig();
|
||||||
expect(config.ldap).toHaveLength(1);
|
expect(config.ldap).toHaveLength(1);
|
||||||
const firstLdap = config.ldap[0];
|
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.url).toEqual(url);
|
||||||
expect(firstLdap.providerName).toEqual(providerName);
|
expect(firstLdap.providerName).toEqual(providerName);
|
||||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||||
|
@ -292,7 +296,7 @@ describe('authConfig', () => {
|
||||||
const config = authConfig();
|
const config = authConfig();
|
||||||
expect(config.ldap).toHaveLength(1);
|
expect(config.ldap).toHaveLength(1);
|
||||||
const firstLdap = config.ldap[0];
|
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.url).toEqual(url);
|
||||||
expect(firstLdap.providerName).toEqual(providerName);
|
expect(firstLdap.providerName).toEqual(providerName);
|
||||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||||
|
@ -323,7 +327,7 @@ describe('authConfig', () => {
|
||||||
const config = authConfig();
|
const config = authConfig();
|
||||||
expect(config.ldap).toHaveLength(1);
|
expect(config.ldap).toHaveLength(1);
|
||||||
const firstLdap = config.ldap[0];
|
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.url).toEqual(url);
|
||||||
expect(firstLdap.providerName).toEqual(providerName);
|
expect(firstLdap.providerName).toEqual(providerName);
|
||||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||||
|
@ -354,7 +358,7 @@ describe('authConfig', () => {
|
||||||
const config = authConfig();
|
const config = authConfig();
|
||||||
expect(config.ldap).toHaveLength(1);
|
expect(config.ldap).toHaveLength(1);
|
||||||
const firstLdap = config.ldap[0];
|
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.url).toEqual(url);
|
||||||
expect(firstLdap.providerName).toEqual(providerName);
|
expect(firstLdap.providerName).toEqual(providerName);
|
||||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||||
|
@ -385,7 +389,7 @@ describe('authConfig', () => {
|
||||||
const config = authConfig();
|
const config = authConfig();
|
||||||
expect(config.ldap).toHaveLength(1);
|
expect(config.ldap).toHaveLength(1);
|
||||||
const firstLdap = config.ldap[0];
|
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.url).toEqual(url);
|
||||||
expect(firstLdap.providerName).toEqual(providerName);
|
expect(firstLdap.providerName).toEqual(providerName);
|
||||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||||
|
@ -416,7 +420,7 @@ describe('authConfig', () => {
|
||||||
const config = authConfig();
|
const config = authConfig();
|
||||||
expect(config.ldap).toHaveLength(1);
|
expect(config.ldap).toHaveLength(1);
|
||||||
const firstLdap = config.ldap[0];
|
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.url).toEqual(url);
|
||||||
expect(firstLdap.providerName).toEqual(providerName);
|
expect(firstLdap.providerName).toEqual(providerName);
|
||||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||||
|
@ -447,7 +451,7 @@ describe('authConfig', () => {
|
||||||
const config = authConfig();
|
const config = authConfig();
|
||||||
expect(config.ldap).toHaveLength(1);
|
expect(config.ldap).toHaveLength(1);
|
||||||
const firstLdap = config.ldap[0];
|
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.url).toEqual(url);
|
||||||
expect(firstLdap.providerName).toEqual(providerName);
|
expect(firstLdap.providerName).toEqual(providerName);
|
||||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -7,7 +7,7 @@ import { registerAs } from '@nestjs/config';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as Joi from 'joi';
|
import * as Joi from 'joi';
|
||||||
|
|
||||||
import { GitlabScope } from './gitlab.enum';
|
import { Theme } from './theme.enum';
|
||||||
import {
|
import {
|
||||||
buildErrorMessage,
|
buildErrorMessage,
|
||||||
ensureNoDuplicatesExist,
|
ensureNoDuplicatesExist,
|
||||||
|
@ -16,9 +16,12 @@ import {
|
||||||
toArrayConfig,
|
toArrayConfig,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
export interface LDAPConfig {
|
export interface InternalIdentifier {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LDAPConfig extends InternalIdentifier {
|
||||||
url: string;
|
url: string;
|
||||||
bindDn?: string;
|
bindDn?: string;
|
||||||
bindCredentials?: string;
|
bindCredentials?: string;
|
||||||
|
@ -27,11 +30,33 @@ export interface LDAPConfig {
|
||||||
searchAttributes: string[];
|
searchAttributes: string[];
|
||||||
userIdField: string;
|
userIdField: string;
|
||||||
displayNameField: string;
|
displayNameField: string;
|
||||||
|
emailField: string;
|
||||||
profilePictureField: string;
|
profilePictureField: string;
|
||||||
tlsCaCerts?: 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 {
|
export interface AuthConfig {
|
||||||
|
common: {
|
||||||
|
allowProfileEdits: boolean;
|
||||||
|
allowChooseUsername: boolean;
|
||||||
|
syncSource?: string;
|
||||||
|
};
|
||||||
session: {
|
session: {
|
||||||
secret: string;
|
secret: string;
|
||||||
lifetime: number;
|
lifetime: number;
|
||||||
|
@ -41,66 +66,27 @@ export interface AuthConfig {
|
||||||
enableRegister: boolean;
|
enableRegister: boolean;
|
||||||
minimalPasswordStrength: number;
|
minimalPasswordStrength: number;
|
||||||
};
|
};
|
||||||
github: {
|
// ToDo: tlsOptions exist in config.json.example. See https://nodejs.org/api/tls.html#tls_tls_connect_options_callback
|
||||||
clientID: string;
|
|
||||||
clientSecret: string;
|
|
||||||
};
|
|
||||||
google: {
|
|
||||||
clientID: string;
|
|
||||||
clientSecret: string;
|
|
||||||
apiKey: string;
|
|
||||||
};
|
|
||||||
gitlab: {
|
|
||||||
identifier: string;
|
|
||||||
providerName: string;
|
|
||||||
baseURL: string;
|
|
||||||
clientID: string;
|
|
||||||
clientSecret: string;
|
|
||||||
scope: GitlabScope;
|
|
||||||
}[];
|
|
||||||
ldap: LDAPConfig[];
|
ldap: LDAPConfig[];
|
||||||
saml: {
|
oidc: OidcConfig[];
|
||||||
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;
|
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authSchema = Joi.object({
|
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: {
|
session: {
|
||||||
secret: Joi.string().label('HD_SESSION_SECRET'),
|
secret: Joi.string().label('HD_SESSION_SECRET'),
|
||||||
lifetime: Joi.number()
|
lifetime: Joi.number()
|
||||||
.default(1209600000) // 14 * 24 * 60 * 60 * 1000ms = 14 days
|
.default(1209600) // 14 * 24 * 60 * 60s = 14 days
|
||||||
.optional()
|
.optional()
|
||||||
.label('HD_SESSION_LIFETIME'),
|
.label('HD_SESSION_LIFETIME'),
|
||||||
},
|
},
|
||||||
|
@ -120,30 +106,6 @@ const authSchema = Joi.object({
|
||||||
.optional()
|
.optional()
|
||||||
.label('HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH'),
|
.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()
|
ldap: Joi.array()
|
||||||
.items(
|
.items(
|
||||||
Joi.object({
|
Joi.object({
|
||||||
|
@ -157,107 +119,49 @@ const authSchema = Joi.object({
|
||||||
searchAttributes: Joi.array().items(Joi.string()).optional(),
|
searchAttributes: Joi.array().items(Joi.string()).optional(),
|
||||||
userIdField: Joi.string().default('uid').optional(),
|
userIdField: Joi.string().default('uid').optional(),
|
||||||
displayNameField: Joi.string().default('displayName').optional(),
|
displayNameField: Joi.string().default('displayName').optional(),
|
||||||
|
emailField: Joi.string().default('mail').optional(),
|
||||||
profilePictureField: Joi.string().default('jpegPhoto').optional(),
|
profilePictureField: Joi.string().default('jpegPhoto').optional(),
|
||||||
tlsCaCerts: Joi.array().items(Joi.string()).optional(),
|
tlsCaCerts: Joi.array().items(Joi.string()).optional(),
|
||||||
}).optional(),
|
}).optional(),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
saml: Joi.array()
|
oidc: Joi.array()
|
||||||
.items(
|
.items(
|
||||||
Joi.object({
|
Joi.object({
|
||||||
identifier: Joi.string(),
|
identifier: Joi.string(),
|
||||||
providerName: Joi.string().default('SAML').optional(),
|
providerName: Joi.string().default('OpenID Connect').optional(),
|
||||||
idpSsoUrl: Joi.string(),
|
issuer: 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(),
|
|
||||||
clientID: Joi.string(),
|
clientID: Joi.string(),
|
||||||
clientSecret: Joi.string(),
|
clientSecret: Joi.string(),
|
||||||
scope: Joi.string().optional(),
|
theme: Joi.string()
|
||||||
rolesClaim: Joi.string().optional(),
|
.valid(...Object.values(Theme))
|
||||||
accessRole: Joi.string().optional(),
|
.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(),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default registerAs('authConfig', () => {
|
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 = (
|
const ldapNames = (
|
||||||
toArrayConfig(process.env.HD_AUTH_LDAP_SERVERS, ',') ?? []
|
toArrayConfig(process.env.HD_AUTH_LDAP_SERVERS, ',') ?? []
|
||||||
).map((name) => name.toUpperCase());
|
).map((name) => name.toUpperCase());
|
||||||
ensureNoDuplicatesExist('LDAP', ldapNames);
|
ensureNoDuplicatesExist('LDAP', ldapNames);
|
||||||
|
|
||||||
const samlNames = (toArrayConfig(process.env.HD_AUTH_SAMLS, ',') ?? []).map(
|
const oidcNames = (
|
||||||
(name) => name.toUpperCase(),
|
toArrayConfig(process.env.HD_AUTH_OIDC_SERVERS, ',') ?? []
|
||||||
);
|
|
||||||
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, ',') ?? []
|
|
||||||
).map((name) => name.toUpperCase());
|
).map((name) => name.toUpperCase());
|
||||||
if (oauth2Names.length !== 0) {
|
ensureNoDuplicatesExist('OIDC', oidcNames);
|
||||||
throw new Error(
|
|
||||||
"OAuth2 auth is currently not yet supported. Please don't configure it",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ensureNoDuplicatesExist('OAuth2', oauth2Names);
|
|
||||||
|
|
||||||
const gitlabs = gitlabNames.map((gitlabName) => {
|
const ldapInstances = ldapNames.map((ldapName) => {
|
||||||
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 caFiles = toArrayConfig(
|
const caFiles = toArrayConfig(
|
||||||
process.env[`HD_AUTH_LDAP_${ldapName}_TLS_CERT_PATHS`],
|
process.env[`HD_AUTH_LDAP_${ldapName}_TLS_CERT_PATHS`],
|
||||||
',',
|
',',
|
||||||
|
@ -271,7 +175,7 @@ export default registerAs('authConfig', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
identifier: ldapName,
|
identifier: ldapName.toLowerCase(),
|
||||||
providerName: process.env[`HD_AUTH_LDAP_${ldapName}_PROVIDER_NAME`],
|
providerName: process.env[`HD_AUTH_LDAP_${ldapName}_PROVIDER_NAME`],
|
||||||
url: process.env[`HD_AUTH_LDAP_${ldapName}_URL`],
|
url: process.env[`HD_AUTH_LDAP_${ldapName}_URL`],
|
||||||
bindDn: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_DN`],
|
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`],
|
userIdField: process.env[`HD_AUTH_LDAP_${ldapName}_USER_ID_FIELD`],
|
||||||
displayNameField:
|
displayNameField:
|
||||||
process.env[`HD_AUTH_LDAP_${ldapName}_DISPLAY_NAME_FIELD`],
|
process.env[`HD_AUTH_LDAP_${ldapName}_DISPLAY_NAME_FIELD`],
|
||||||
|
emailField: process.env[`HD_AUTH_LDAP_${ldapName}_EMAIL_FIELD`],
|
||||||
profilePictureField:
|
profilePictureField:
|
||||||
process.env[`HD_AUTH_LDAP_${ldapName}_PROFILE_PICTURE_FIELD`],
|
process.env[`HD_AUTH_LDAP_${ldapName}_PROFILE_PICTURE_FIELD`],
|
||||||
tlsCaCerts: tlsCaCerts,
|
tlsCaCerts: tlsCaCerts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const samls = samlNames.map((samlName) => {
|
const oidcInstances = oidcNames.map((oidcName) => ({
|
||||||
return {
|
identifier: oidcName.toLowerCase(),
|
||||||
identifier: samlName,
|
providerName: process.env[`HD_AUTH_OIDC_${oidcName}_PROVIDER_NAME`],
|
||||||
providerName: process.env[`HD_AUTH_SAML_${samlName}_PROVIDER_NAME`],
|
issuer: process.env[`HD_AUTH_OIDC_${oidcName}_ISSUER`],
|
||||||
idpSsoUrl: process.env[`HD_AUTH_SAML_${samlName}_IDP_SSO_URL`],
|
clientID: process.env[`HD_AUTH_OIDC_${oidcName}_CLIENT_ID`],
|
||||||
idpCert: process.env[`HD_AUTH_SAML_${samlName}_IDP_CERT`],
|
clientSecret: process.env[`HD_AUTH_OIDC_${oidcName}_CLIENT_SECRET`],
|
||||||
clientCert: process.env[`HD_AUTH_SAML_${samlName}_CLIENT_CERT`],
|
theme: process.env[`HD_AUTH_OIDC_${oidcName}_THEME`],
|
||||||
// ToDo: (default: config.serverURL) will be build on-the-fly in the config/index.js from domain, urlAddPort and urlPath.
|
authorizeUrl: process.env[`HD_AUTH_OIDC_${oidcName}_AUTHORIZE_URL`],
|
||||||
// https://github.com/hedgedoc/hedgedoc/issues/5043
|
tokenUrl: process.env[`HD_AUTH_OIDC_${oidcName}_TOKEN_URL`],
|
||||||
issuer: process.env[`HD_AUTH_SAML_${samlName}_ISSUER`],
|
userinfoUrl: process.env[`HD_AUTH_OIDC_${oidcName}_USERINFO_URL`],
|
||||||
identifierFormat:
|
scope: process.env[`HD_AUTH_OIDC_${oidcName}_SCOPE`],
|
||||||
process.env[`HD_AUTH_SAML_${samlName}_IDENTIFIER_FORMAT`],
|
userIdField: process.env[`HD_AUTH_OIDC_${oidcName}_USER_ID_FIELD`],
|
||||||
disableRequestedAuthnContext:
|
userNameField: process.env[`HD_AUTH_OIDC_${oidcName}_USER_NAME_FIELD`],
|
||||||
process.env[`HD_AUTH_SAML_${samlName}_DISABLE_REQUESTED_AUTHN_CONTEXT`],
|
displayNameField:
|
||||||
groupAttribute: process.env[`HD_AUTH_SAML_${samlName}_GROUP_ATTRIBUTE`],
|
process.env[`HD_AUTH_OIDC_${oidcName}_DISPLAY_NAME_FIELD`],
|
||||||
requiredGroups: toArrayConfig(
|
profilePictureField:
|
||||||
process.env[`HD_AUTH_SAML_${samlName}_REQUIRED_GROUPS`],
|
process.env[`HD_AUTH_OIDC_${oidcName}_PROFILE_PICTURE_FIELD`],
|
||||||
'|',
|
emailField: process.env[`HD_AUTH_OIDC_${oidcName}_EMAIL_FIELD`],
|
||||||
),
|
}));
|
||||||
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 oauth2s = oauth2Names.map((oauth2Name) => {
|
let syncSource = process.env.HD_AUTH_SYNC_SOURCE;
|
||||||
return {
|
if (syncSource !== undefined) {
|
||||||
identifier: oauth2Name,
|
syncSource = syncSource.toLowerCase();
|
||||||
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",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authConfig = authSchema.validate(
|
const authConfig = authSchema.validate(
|
||||||
{
|
{
|
||||||
|
common: {
|
||||||
|
allowProfileEdits: process.env.HD_AUTH_ALLOW_PROFILE_EDITS,
|
||||||
|
allowChooseUsername: process.env.HD_AUTH_ALLOW_CHOOSE_USERNAME,
|
||||||
|
syncSource: syncSource,
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
secret: process.env.HD_SESSION_SECRET,
|
secret: process.env.HD_SESSION_SECRET,
|
||||||
lifetime: parseOptionalNumber(process.env.HD_SESSION_LIFETIME),
|
lifetime: parseOptionalNumber(process.env.HD_SESSION_LIFETIME),
|
||||||
|
@ -382,19 +239,8 @@ export default registerAs('authConfig', () => {
|
||||||
process.env.HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH,
|
process.env.HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
github: {
|
ldap: ldapInstances,
|
||||||
clientID: process.env.HD_AUTH_GITHUB_CLIENT_ID,
|
oidc: oidcInstances,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
abortEarly: false,
|
abortEarly: false,
|
||||||
|
@ -404,14 +250,6 @@ export default registerAs('authConfig', () => {
|
||||||
if (authConfig.error) {
|
if (authConfig.error) {
|
||||||
const errorMessages = authConfig.error.details
|
const errorMessages = authConfig.error.details
|
||||||
.map((detail) => detail.message)
|
.map((detail) => detail.message)
|
||||||
.map((error) =>
|
|
||||||
replaceAuthErrorsWithEnvironmentVariables(
|
|
||||||
error,
|
|
||||||
'gitlab',
|
|
||||||
'HD_AUTH_GITLAB_',
|
|
||||||
gitlabNames,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.map((error) =>
|
.map((error) =>
|
||||||
replaceAuthErrorsWithEnvironmentVariables(
|
replaceAuthErrorsWithEnvironmentVariables(
|
||||||
error,
|
error,
|
||||||
|
@ -423,17 +261,9 @@ export default registerAs('authConfig', () => {
|
||||||
.map((error) =>
|
.map((error) =>
|
||||||
replaceAuthErrorsWithEnvironmentVariables(
|
replaceAuthErrorsWithEnvironmentVariables(
|
||||||
error,
|
error,
|
||||||
'saml',
|
'oidc',
|
||||||
'HD_AUTH_SAML_',
|
'HD_AUTH_OIDC_',
|
||||||
samlNames,
|
oidcNames,
|
||||||
),
|
|
||||||
)
|
|
||||||
.map((error) =>
|
|
||||||
replaceAuthErrorsWithEnvironmentVariables(
|
|
||||||
error,
|
|
||||||
'oauth2',
|
|
||||||
'HD_AUTH_OAUTH2_',
|
|
||||||
oauth2Names,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
throw new Error(buildErrorMessage(errorMessages));
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -10,6 +10,10 @@ import { AuthConfig } from '../auth.config';
|
||||||
|
|
||||||
export function createDefaultMockAuthConfig(): AuthConfig {
|
export function createDefaultMockAuthConfig(): AuthConfig {
|
||||||
return {
|
return {
|
||||||
|
common: {
|
||||||
|
allowProfileEdits: true,
|
||||||
|
allowChooseUsername: true,
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
secret: 'my_secret',
|
secret: 'my_secret',
|
||||||
lifetime: 1209600000,
|
lifetime: 1209600000,
|
||||||
|
@ -19,19 +23,8 @@ export function createDefaultMockAuthConfig(): AuthConfig {
|
||||||
enableRegister: true,
|
enableRegister: true,
|
||||||
minimalPasswordStrength: 2,
|
minimalPasswordStrength: 2,
|
||||||
},
|
},
|
||||||
github: {
|
|
||||||
clientID: '',
|
|
||||||
clientSecret: '',
|
|
||||||
},
|
|
||||||
google: {
|
|
||||||
clientID: '',
|
|
||||||
clientSecret: '',
|
|
||||||
apiKey: '',
|
|
||||||
},
|
|
||||||
gitlab: [],
|
|
||||||
ldap: [],
|
ldap: [],
|
||||||
saml: [],
|
oidc: [],
|
||||||
oauth2: [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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', () => {
|
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', () => {
|
it('"ldap[0].url', () => {
|
||||||
expect(
|
expect(
|
||||||
replaceAuthErrorsWithEnvironmentVariables(
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -66,61 +66,24 @@ export function replaceAuthErrorsWithEnvironmentVariables(
|
||||||
newMessage = newMessage.replace('.providerName', '_PROVIDER_NAME');
|
newMessage = newMessage.replace('.providerName', '_PROVIDER_NAME');
|
||||||
newMessage = newMessage.replace('.baseURL', '_BASE_URL');
|
newMessage = newMessage.replace('.baseURL', '_BASE_URL');
|
||||||
newMessage = newMessage.replace('.clientID', '_CLIENT_ID');
|
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('.url', '_URL');
|
||||||
|
newMessage = newMessage.replace('.clientSecret', '_CLIENT_SECRET');
|
||||||
newMessage = newMessage.replace('.bindDn', '_BIND_DN');
|
newMessage = newMessage.replace('.bindDn', '_BIND_DN');
|
||||||
newMessage = newMessage.replace('.bindCredentials', '_BIND_CREDENTIALS');
|
newMessage = newMessage.replace('.bindCredentials', '_BIND_CREDENTIALS');
|
||||||
newMessage = newMessage.replace('.searchBase', '_SEARCH_BASE');
|
newMessage = newMessage.replace('.searchBase', '_SEARCH_BASE');
|
||||||
newMessage = newMessage.replace('.searchFilter', '_SEARCH_FILTER');
|
newMessage = newMessage.replace('.searchFilter', '_SEARCH_FILTER');
|
||||||
newMessage = newMessage.replace('.searchAttributes', '_SEARCH_ATTRIBUTES');
|
newMessage = newMessage.replace('.searchAttributes', '_SEARCH_ATTRIBUTES');
|
||||||
newMessage = newMessage.replace('.userIdField', '_USER_ID_FIELD');
|
newMessage = newMessage.replace('.userIdField', '_USER_ID_FIELD');
|
||||||
|
newMessage = newMessage.replace('.userNameField', '_USER_NAME_FIELD');
|
||||||
newMessage = newMessage.replace('.displayNameField', '_DISPLAY_NAME_FIELD');
|
newMessage = newMessage.replace('.displayNameField', '_DISPLAY_NAME_FIELD');
|
||||||
|
newMessage = newMessage.replace('.emailField', '_EMAIL_FIELD');
|
||||||
newMessage = newMessage.replace(
|
newMessage = newMessage.replace(
|
||||||
'.profilePictureField',
|
'.profilePictureField',
|
||||||
'_PROFILE_PICTURE_FIELD',
|
'_PROFILE_PICTURE_FIELD',
|
||||||
);
|
);
|
||||||
newMessage = newMessage.replace('.tlsCaCerts', '_TLS_CERT_PATHS');
|
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('.issuer', '_ISSUER');
|
||||||
newMessage = newMessage.replace('.identifierFormat', '_IDENTIFIER_FORMAT');
|
newMessage = newMessage.replace('.theme', '_THEME');
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
return newMessage;
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -16,29 +16,15 @@ import {
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
|
|
||||||
import { GuestAccess } from '../config/guest_access.enum';
|
import { GuestAccess } from '../config/guest_access.enum';
|
||||||
|
import { ProviderType } from '../identity/provider-type.enum';
|
||||||
import { ServerVersion } from '../monitoring/server-status.dto';
|
import { ServerVersion } from '../monitoring/server-status.dto';
|
||||||
import { BaseDto } from '../utils/base.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 =
|
export type AuthProviderTypeWithCustomName =
|
||||||
| AuthProviderType.LDAP
|
| ProviderType.LDAP
|
||||||
| AuthProviderType.OAUTH2
|
| ProviderType.OIDC;
|
||||||
| AuthProviderType.SAML
|
|
||||||
| AuthProviderType.GITLAB;
|
|
||||||
|
|
||||||
export type AuthProviderTypeWithoutCustomName =
|
export type AuthProviderTypeWithoutCustomName = ProviderType.LOCAL;
|
||||||
| AuthProviderType.LOCAL
|
|
||||||
| AuthProviderType.GITHUB
|
|
||||||
| AuthProviderType.GOOGLE;
|
|
||||||
|
|
||||||
export class AuthProviderWithoutCustomNameDto extends BaseDto {
|
export class AuthProviderWithoutCustomNameDto extends BaseDto {
|
||||||
/**
|
/**
|
||||||
|
@ -70,6 +56,14 @@ export class AuthProviderWithCustomNameDto extends BaseDto {
|
||||||
*/
|
*/
|
||||||
@IsString()
|
@IsString()
|
||||||
providerName: string;
|
providerName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The theme to apply for the login button.
|
||||||
|
* @example gitlab
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
theme?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthProviderDto =
|
export type AuthProviderDto =
|
||||||
|
@ -137,6 +131,18 @@ export class FrontendConfigDto extends BaseDto {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
allowRegister: boolean;
|
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?
|
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -12,13 +12,12 @@ import { AuthConfig } from '../config/auth.config';
|
||||||
import { CustomizationConfig } from '../config/customization.config';
|
import { CustomizationConfig } from '../config/customization.config';
|
||||||
import { DefaultAccessLevel } from '../config/default-access-level.enum';
|
import { DefaultAccessLevel } from '../config/default-access-level.enum';
|
||||||
import { ExternalServicesConfig } from '../config/external-services.config';
|
import { ExternalServicesConfig } from '../config/external-services.config';
|
||||||
import { GitlabScope } from '../config/gitlab.enum';
|
|
||||||
import { GuestAccess } from '../config/guest_access.enum';
|
import { GuestAccess } from '../config/guest_access.enum';
|
||||||
import { Loglevel } from '../config/loglevel.enum';
|
import { Loglevel } from '../config/loglevel.enum';
|
||||||
import { NoteConfig } from '../config/note.config';
|
import { NoteConfig } from '../config/note.config';
|
||||||
|
import { ProviderType } from '../identity/provider-type.enum';
|
||||||
import { LoggerModule } from '../logger/logger.module';
|
import { LoggerModule } from '../logger/logger.module';
|
||||||
import { getServerVersionFromPackageJson } from '../utils/serverVersion';
|
import { getServerVersionFromPackageJson } from '../utils/serverVersion';
|
||||||
import { AuthProviderType } from './frontend-config.dto';
|
|
||||||
import { FrontendConfigService } from './frontend-config.service';
|
import { FrontendConfigService } from './frontend-config.service';
|
||||||
|
|
||||||
/* eslint-disable
|
/* eslint-disable
|
||||||
|
@ -28,6 +27,11 @@ import { FrontendConfigService } from './frontend-config.service';
|
||||||
describe('FrontendConfigService', () => {
|
describe('FrontendConfigService', () => {
|
||||||
const domain = 'http://md.example.com';
|
const domain = 'http://md.example.com';
|
||||||
const emptyAuthConfig: AuthConfig = {
|
const emptyAuthConfig: AuthConfig = {
|
||||||
|
common: {
|
||||||
|
allowProfileEdits: true,
|
||||||
|
allowChooseUsername: true,
|
||||||
|
syncSource: undefined,
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
secret: 'my-secret',
|
secret: 'my-secret',
|
||||||
lifetime: 1209600000,
|
lifetime: 1209600000,
|
||||||
|
@ -37,41 +41,11 @@ describe('FrontendConfigService', () => {
|
||||||
enableRegister: false,
|
enableRegister: false,
|
||||||
minimalPasswordStrength: 2,
|
minimalPasswordStrength: 2,
|
||||||
},
|
},
|
||||||
github: {
|
|
||||||
clientID: undefined,
|
|
||||||
clientSecret: undefined,
|
|
||||||
},
|
|
||||||
google: {
|
|
||||||
clientID: undefined,
|
|
||||||
clientSecret: undefined,
|
|
||||||
apiKey: undefined,
|
|
||||||
},
|
|
||||||
gitlab: [],
|
|
||||||
ldap: [],
|
ldap: [],
|
||||||
saml: [],
|
oidc: [],
|
||||||
oauth2: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('getAuthProviders', () => {
|
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'] = [
|
const ldap: AuthConfig['ldap'] = [
|
||||||
{
|
{
|
||||||
identifier: 'ldapTestIdentifier',
|
identifier: 'ldapTestIdentifier',
|
||||||
|
@ -83,58 +57,28 @@ describe('FrontendConfigService', () => {
|
||||||
searchFilter: 'ldapTestSearchFilter',
|
searchFilter: 'ldapTestSearchFilter',
|
||||||
searchAttributes: ['ldapTestSearchAttribute'],
|
searchAttributes: ['ldapTestSearchAttribute'],
|
||||||
userIdField: 'ldapTestUserId',
|
userIdField: 'ldapTestUserId',
|
||||||
|
emailField: 'ldapEmailField',
|
||||||
displayNameField: 'ldapTestDisplayName',
|
displayNameField: 'ldapTestDisplayName',
|
||||||
profilePictureField: 'ldapTestProfilePicture',
|
profilePictureField: 'ldapTestProfilePicture',
|
||||||
tlsCaCerts: ['ldapTestTlsCa'],
|
tlsCaCerts: ['ldapTestTlsCa'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const saml: AuthConfig['saml'] = [
|
const oidc: AuthConfig['oidc'] = [
|
||||||
{
|
{
|
||||||
identifier: 'samlTestIdentifier',
|
identifier: 'oidcTestIdentifier',
|
||||||
providerName: 'samlTestName',
|
providerName: 'oidcTestProviderName',
|
||||||
idpSsoUrl: 'samlTestUrl',
|
issuer: 'oidcTestIssuer',
|
||||||
idpCert: 'samlTestCert',
|
clientID: 'oidcTestId',
|
||||||
clientCert: 'samlTestClientCert',
|
clientSecret: 'oidcTestSecret',
|
||||||
issuer: 'samlTestIssuer',
|
scope: 'openid profile email',
|
||||||
identifierFormat: 'samlTestUrl',
|
userIdField: '',
|
||||||
disableRequestedAuthnContext: 'samlTestUrl',
|
userNameField: '',
|
||||||
groupAttribute: 'samlTestUrl',
|
displayNameField: '',
|
||||||
requiredGroups: ['samlTestUrl'],
|
profilePictureField: '',
|
||||||
externalGroups: ['samlTestUrl'],
|
emailField: '',
|
||||||
attribute: {
|
|
||||||
id: 'samlTestUrl',
|
|
||||||
username: 'samlTestUrl',
|
|
||||||
email: 'samlTestUrl',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const oauth2: AuthConfig['oauth2'] = [
|
for (const authConfigConfigured of [ldap, oidc]) {
|
||||||
{
|
|
||||||
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,
|
|
||||||
]) {
|
|
||||||
it(`works with ${JSON.stringify(authConfigConfigured)}`, async () => {
|
it(`works with ${JSON.stringify(authConfigConfigured)}`, async () => {
|
||||||
const appConfig: AppConfig = {
|
const appConfig: AppConfig = {
|
||||||
baseUrl: domain,
|
baseUrl: domain,
|
||||||
|
@ -182,83 +126,41 @@ describe('FrontendConfigService', () => {
|
||||||
}).compile();
|
}).compile();
|
||||||
const service = module.get(FrontendConfigService);
|
const service = module.get(FrontendConfigService);
|
||||||
const config = await service.getFrontendConfig();
|
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) {
|
if (authConfig.local.enableLogin) {
|
||||||
expect(config.authProviders).toContainEqual({
|
expect(config.authProviders).toContainEqual({
|
||||||
type: AuthProviderType.LOCAL,
|
type: ProviderType.LOCAL,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
expect(
|
expect(
|
||||||
config.authProviders.filter(
|
config.authProviders.filter(
|
||||||
(provider) => provider.type === AuthProviderType.GITLAB,
|
(provider) => provider.type === ProviderType.LDAP,
|
||||||
).length,
|
|
||||||
).toEqual(authConfig.gitlab.length);
|
|
||||||
expect(
|
|
||||||
config.authProviders.filter(
|
|
||||||
(provider) => provider.type === AuthProviderType.LDAP,
|
|
||||||
).length,
|
).length,
|
||||||
).toEqual(authConfig.ldap.length);
|
).toEqual(authConfig.ldap.length);
|
||||||
expect(
|
expect(
|
||||||
config.authProviders.filter(
|
config.authProviders.filter(
|
||||||
(provider) => provider.type === AuthProviderType.SAML,
|
(provider) => provider.type === ProviderType.OIDC,
|
||||||
).length,
|
).length,
|
||||||
).toEqual(authConfig.saml.length);
|
).toEqual(authConfig.oidc.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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (authConfig.ldap.length > 0) {
|
if (authConfig.ldap.length > 0) {
|
||||||
expect(
|
expect(
|
||||||
config.authProviders.find(
|
config.authProviders.find(
|
||||||
(provider) => provider.type === AuthProviderType.LDAP,
|
(provider) => provider.type === ProviderType.LDAP,
|
||||||
),
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
type: AuthProviderType.LDAP,
|
type: ProviderType.LDAP,
|
||||||
providerName: authConfig.ldap[0].providerName,
|
providerName: authConfig.ldap[0].providerName,
|
||||||
identifier: authConfig.ldap[0].identifier,
|
identifier: authConfig.ldap[0].identifier,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (authConfig.saml.length > 0) {
|
if (authConfig.oidc.length > 0) {
|
||||||
expect(
|
expect(
|
||||||
config.authProviders.find(
|
config.authProviders.find(
|
||||||
(provider) => provider.type === AuthProviderType.SAML,
|
(provider) => provider.type === ProviderType.OIDC,
|
||||||
),
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
type: AuthProviderType.SAML,
|
type: ProviderType.OIDC,
|
||||||
providerName: authConfig.saml[0].providerName,
|
providerName: authConfig.oidc[0].providerName,
|
||||||
identifier: authConfig.saml[0].identifier,
|
identifier: authConfig.oidc[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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -15,11 +15,11 @@ import externalServicesConfiguration, {
|
||||||
ExternalServicesConfig,
|
ExternalServicesConfig,
|
||||||
} from '../config/external-services.config';
|
} from '../config/external-services.config';
|
||||||
import noteConfiguration, { NoteConfig } from '../config/note.config';
|
import noteConfiguration, { NoteConfig } from '../config/note.config';
|
||||||
|
import { ProviderType } from '../identity/provider-type.enum';
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
import { getServerVersionFromPackageJson } from '../utils/serverVersion';
|
import { getServerVersionFromPackageJson } from '../utils/serverVersion';
|
||||||
import {
|
import {
|
||||||
AuthProviderDto,
|
AuthProviderDto,
|
||||||
AuthProviderType,
|
|
||||||
BrandingDto,
|
BrandingDto,
|
||||||
FrontendConfigDto,
|
FrontendConfigDto,
|
||||||
SpecialUrlsDto,
|
SpecialUrlsDto,
|
||||||
|
@ -47,6 +47,8 @@ export class FrontendConfigService {
|
||||||
return {
|
return {
|
||||||
guestAccess: this.noteConfig.guestAccess,
|
guestAccess: this.noteConfig.guestAccess,
|
||||||
allowRegister: this.authConfig.local.enableRegister,
|
allowRegister: this.authConfig.local.enableRegister,
|
||||||
|
allowProfileEdits: this.authConfig.common.allowProfileEdits,
|
||||||
|
allowChooseUsername: this.authConfig.common.allowChooseUsername,
|
||||||
authProviders: this.getAuthProviders(),
|
authProviders: this.getAuthProviders(),
|
||||||
branding: this.getBranding(),
|
branding: this.getBranding(),
|
||||||
maxDocumentLength: this.noteConfig.maxDocumentLength,
|
maxDocumentLength: this.noteConfig.maxDocumentLength,
|
||||||
|
@ -63,45 +65,22 @@ export class FrontendConfigService {
|
||||||
const providers: AuthProviderDto[] = [];
|
const providers: AuthProviderDto[] = [];
|
||||||
if (this.authConfig.local.enableLogin) {
|
if (this.authConfig.local.enableLogin) {
|
||||||
providers.push({
|
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) => {
|
this.authConfig.ldap.forEach((ldapEntry) => {
|
||||||
providers.push({
|
providers.push({
|
||||||
type: AuthProviderType.LDAP,
|
type: ProviderType.LDAP,
|
||||||
providerName: ldapEntry.providerName,
|
providerName: ldapEntry.providerName,
|
||||||
identifier: ldapEntry.identifier,
|
identifier: ldapEntry.identifier,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.authConfig.oauth2.forEach((oauth2Entry) => {
|
this.authConfig.oidc.forEach((openidConnectEntry) => {
|
||||||
providers.push({
|
providers.push({
|
||||||
type: AuthProviderType.OAUTH2,
|
type: ProviderType.OIDC,
|
||||||
providerName: oauth2Entry.providerName,
|
providerName: openidConnectEntry.providerName,
|
||||||
identifier: oauth2Entry.identifier,
|
identifier: openidConnectEntry.identifier,
|
||||||
});
|
theme: openidConnectEntry.theme,
|
||||||
});
|
|
||||||
this.authConfig.saml.forEach((samlEntry) => {
|
|
||||||
providers.push({
|
|
||||||
type: AuthProviderType.SAML,
|
|
||||||
providerName: samlEntry.providerName,
|
|
||||||
identifier: samlEntry.identifier,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return providers;
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -41,21 +41,14 @@ export class Identity {
|
||||||
providerType: string;
|
providerType: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The name of the provider.
|
* The identifier of the provider.
|
||||||
* Only set if there are multiple provider of that type (e.g. gitlab)
|
* Only set if there are multiple providers of that type (e.g. OIDC)
|
||||||
*/
|
*/
|
||||||
@Column({
|
@Column({
|
||||||
nullable: true,
|
nullable: true,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
})
|
})
|
||||||
providerName: string | null;
|
providerIdentifier: 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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the identity was created.
|
* When the identity was created.
|
||||||
|
@ -78,15 +71,6 @@ export class Identity {
|
||||||
})
|
})
|
||||||
providerUserId: string | null;
|
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
|
* The hash of the password
|
||||||
* Only set when the type of the identity is local
|
* Only set when the type of the identity is local
|
||||||
|
@ -100,15 +84,13 @@ export class Identity {
|
||||||
public static create(
|
public static create(
|
||||||
user: User,
|
user: User,
|
||||||
providerType: ProviderType,
|
providerType: ProviderType,
|
||||||
syncSource: boolean,
|
providerIdentifier: string | null,
|
||||||
): Omit<Identity, 'id' | 'createdAt' | 'updatedAt'> {
|
): Omit<Identity, 'id' | 'createdAt' | 'updatedAt'> {
|
||||||
const newIdentity = new Identity();
|
const newIdentity = new Identity();
|
||||||
newIdentity.user = Promise.resolve(user);
|
newIdentity.user = Promise.resolve(user);
|
||||||
newIdentity.providerType = providerType;
|
newIdentity.providerType = providerType;
|
||||||
newIdentity.providerName = null;
|
newIdentity.providerIdentifier = providerIdentifier;
|
||||||
newIdentity.syncSource = syncSource;
|
|
||||||
newIdentity.providerUserId = null;
|
newIdentity.providerUserId = null;
|
||||||
newIdentity.oAuthAccessToken = null;
|
|
||||||
newIdentity.passwordHash = null;
|
newIdentity.passwordHash = null;
|
||||||
return newIdentity;
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { LoggerModule } from '../logger/logger.module';
|
import { LoggerModule } from '../logger/logger.module';
|
||||||
|
@ -12,24 +11,18 @@ import { User } from '../users/user.entity';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { Identity } from './identity.entity';
|
import { Identity } from './identity.entity';
|
||||||
import { IdentityService } from './identity.service';
|
import { IdentityService } from './identity.service';
|
||||||
import { LdapAuthGuard, LdapStrategy } from './ldap/ldap.strategy';
|
import { LdapService } from './ldap/ldap.service';
|
||||||
import { LocalAuthGuard, LocalStrategy } from './local/local.strategy';
|
import { LocalService } from './local/local.service';
|
||||||
|
import { OidcService } from './oidc/oidc.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Identity, User]),
|
TypeOrmModule.forFeature([Identity, User]),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
PassportModule,
|
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [IdentityService, LdapService, LocalService, OidcService],
|
||||||
IdentityService,
|
exports: [IdentityService, LdapService, LocalService, OidcService],
|
||||||
LocalStrategy,
|
|
||||||
LdapStrategy,
|
|
||||||
LdapAuthGuard,
|
|
||||||
LocalAuthGuard,
|
|
||||||
],
|
|
||||||
exports: [IdentityService, LocalStrategy, LdapStrategy],
|
|
||||||
})
|
})
|
||||||
export class IdentityModule {}
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import {
|
import {
|
||||||
OptionsGraph,
|
Inject,
|
||||||
OptionsType,
|
Injectable,
|
||||||
zxcvbnAsync,
|
InternalServerErrorException,
|
||||||
zxcvbnOptions,
|
} from '@nestjs/common';
|
||||||
} from '@zxcvbn-ts/core';
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
import {
|
import { DataSource, Repository } from 'typeorm';
|
||||||
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 AuthConfiguration, { AuthConfig } from '../config/auth.config';
|
||||||
import {
|
import { NotInDBError } from '../errors/errors';
|
||||||
InvalidCredentialsError,
|
|
||||||
NoLocalIdentityError,
|
|
||||||
NotInDBError,
|
|
||||||
PasswordTooWeakError,
|
|
||||||
} from '../errors/errors';
|
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
|
import { FullUserInfoDto } from '../users/user-info.dto';
|
||||||
import { User } from '../users/user.entity';
|
import { User } from '../users/user.entity';
|
||||||
import { checkPassword, hashPassword } from '../utils/password';
|
import { UsersService } from '../users/users.service';
|
||||||
import { Identity } from './identity.entity';
|
import { Identity } from './identity.entity';
|
||||||
|
import { PendingUserConfirmationDto } from './pending-user-confirmation.dto';
|
||||||
import { ProviderType } from './provider-type.enum';
|
import { ProviderType } from './provider-type.enum';
|
||||||
import { getFirstIdentityFromUser } from './utils';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IdentityService {
|
export class IdentityService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: ConsoleLoggerService,
|
private readonly logger: ConsoleLoggerService,
|
||||||
|
private usersService: UsersService,
|
||||||
|
@InjectDataSource()
|
||||||
|
private dataSource: DataSource,
|
||||||
|
@Inject(AuthConfiguration.KEY)
|
||||||
|
private authConfig: AuthConfig,
|
||||||
@InjectRepository(Identity)
|
@InjectRepository(Identity)
|
||||||
private identityRepository: Repository<Identity>,
|
private identityRepository: Repository<Identity>,
|
||||||
@Inject(authConfiguration.KEY)
|
|
||||||
private authConfig: AuthConfig,
|
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(IdentityService.name);
|
this.logger.setContext(IdentityService.name);
|
||||||
const options: OptionsType = {
|
}
|
||||||
dictionary: {
|
|
||||||
...zxcvbnCommonDictionary,
|
/**
|
||||||
...zxcvbnEnDictionary,
|
* Determines if the identity should be updated
|
||||||
},
|
*
|
||||||
graphs: adjacencyGraphs as OptionsGraph,
|
* @param authProviderIdentifier The identifier of the auth source
|
||||||
translations: zxcvbnEnTranslations,
|
* @return true if the authProviderIdentifier is the sync source, false otherwise
|
||||||
};
|
*/
|
||||||
zxcvbnOptions.setOptions(options);
|
mayUpdateIdentity(authProviderIdentifier: string): boolean {
|
||||||
|
return this.authConfig.common.syncSource === authProviderIdentifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -61,15 +51,18 @@ export class IdentityService {
|
||||||
* Retrieve an identity by userId and providerType.
|
* Retrieve an identity by userId and providerType.
|
||||||
* @param {string} userId - the userId of the wanted identity
|
* @param {string} userId - the userId of the wanted identity
|
||||||
* @param {ProviderType} providerType - the providerType 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(
|
async getIdentityFromUserIdAndProviderType(
|
||||||
userId: string,
|
userId: string,
|
||||||
providerType: ProviderType,
|
providerType: ProviderType,
|
||||||
|
providerIdentifier?: string,
|
||||||
): Promise<Identity> {
|
): Promise<Identity> {
|
||||||
const identity = await this.identityRepository.findOne({
|
const identity = await this.identityRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
providerUserId: userId,
|
providerUserId: userId,
|
||||||
providerType: providerType,
|
providerType,
|
||||||
|
providerIdentifier,
|
||||||
},
|
},
|
||||||
relations: ['user'],
|
relations: ['user'],
|
||||||
});
|
});
|
||||||
|
@ -79,138 +72,81 @@ export class IdentityService {
|
||||||
return identity;
|
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
|
* @async
|
||||||
* Create a new generic identity.
|
* Create a new generic identity.
|
||||||
* @param {User} user - the user the identity should be added to
|
* @param {User} user - the user the identity should be added to
|
||||||
* @param {ProviderType} providerType - the providerType of the identity
|
* @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
|
* @return {Identity} the new local identity
|
||||||
*/
|
*/
|
||||||
async createIdentity(
|
async createIdentity(
|
||||||
user: User,
|
user: User,
|
||||||
providerType: ProviderType,
|
providerType: ProviderType,
|
||||||
userId: string,
|
providerIdentifier: string,
|
||||||
|
providerUserId: string,
|
||||||
): Promise<Identity> {
|
): Promise<Identity> {
|
||||||
const identity = Identity.create(user, providerType, false);
|
const identity = Identity.create(user, providerType, providerIdentifier);
|
||||||
identity.providerUserId = userId;
|
identity.providerUserId = providerUserId;
|
||||||
return await this.identityRepository.save(identity);
|
return await this.identityRepository.save(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @async
|
* Creates a new user with the given user data and the session data.
|
||||||
* Create a new identity for internal auth
|
*
|
||||||
* @param {User} user - the user the identity should be added to
|
* @param {FullUserInfoDto} sessionUserData The user data from the session
|
||||||
* @param {string} password - the password the identity should have
|
* @param {PendingUserConfirmationDto} updatedUserData The updated user data from the API
|
||||||
* @return {Identity} the new local identity
|
* @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> {
|
async createUserWithIdentity(
|
||||||
const identity = Identity.create(user, ProviderType.LOCAL, false);
|
sessionUserData: FullUserInfoDto,
|
||||||
identity.passwordHash = await hashPassword(password);
|
updatedUserData: PendingUserConfirmationDto,
|
||||||
return await this.identityRepository.save(identity);
|
authProviderType: ProviderType,
|
||||||
}
|
authProviderIdentifier: string,
|
||||||
|
providerUserId: string,
|
||||||
/**
|
|
||||||
* @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> {
|
): Promise<Identity> {
|
||||||
const internalIdentity: Identity | undefined =
|
const profileEditsAllowed = this.authConfig.common.allowProfileEdits;
|
||||||
await getFirstIdentityFromUser(user, ProviderType.LOCAL);
|
const chooseUsernameAllowed = this.authConfig.common.allowChooseUsername;
|
||||||
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 username = (
|
||||||
* @async
|
chooseUsernameAllowed
|
||||||
* Checks if the user and password combination matches
|
? updatedUserData.username
|
||||||
* @param {User} user - the user to use
|
: sessionUserData.username
|
||||||
* @param {string} password - the password to use
|
) as Lowercase<string>;
|
||||||
* @throws {InvalidCredentialsError} the password and user do not match
|
const displayName = profileEditsAllowed
|
||||||
* @throws {NoLocalIdentityError} the specified user has no internal identity
|
? updatedUserData.displayName
|
||||||
*/
|
: sessionUserData.displayName;
|
||||||
async checkLocalPassword(user: User, password: string): Promise<void> {
|
const photoUrl = profileEditsAllowed
|
||||||
const internalIdentity: Identity | undefined =
|
? updatedUserData.profilePicture
|
||||||
await getFirstIdentityFromUser(user, ProviderType.LOCAL);
|
: sessionUserData.photoUrl;
|
||||||
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 queryRunner = this.dataSource.createQueryRunner();
|
||||||
* @async
|
await queryRunner.startTransaction();
|
||||||
* Check if the password is strong enough.
|
try {
|
||||||
* This check is performed against the minimalPasswordStrength of the {@link AuthConfig}.
|
const user = await this.usersService.createUser(
|
||||||
* @param {string} password - the password to check
|
username,
|
||||||
* @throws {PasswordTooWeakError} the password is too weak
|
displayName,
|
||||||
*/
|
sessionUserData.email,
|
||||||
async checkPasswordStrength(password: string): Promise<void> {
|
photoUrl,
|
||||||
const result = await zxcvbnAsync(password);
|
);
|
||||||
if (result.score < this.authConfig.local.minimalPasswordStrength) {
|
const identity = await this.createIdentity(
|
||||||
throw new PasswordTooWeakError();
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum ProviderType {
|
export enum ProviderType {
|
||||||
|
GUEST = 'guest',
|
||||||
LOCAL = 'local',
|
LOCAL = 'local',
|
||||||
LDAP = 'ldap',
|
LDAP = 'ldap',
|
||||||
SAML = 'saml',
|
OIDC = 'oidc',
|
||||||
OAUTH2 = 'oauth2',
|
|
||||||
GITLAB = 'gitlab',
|
|
||||||
GITHUB = 'github',
|
|
||||||
GOOGLE = 'google',
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -10,13 +10,20 @@ import {
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
import { CompleteRequest } from '../api/utils/request.type';
|
import { CompleteRequest } from '../api/utils/request.type';
|
||||||
import { GuestAccess } from '../config/guest_access.enum';
|
import { GuestAccess } from '../config/guest_access.enum';
|
||||||
import noteConfiguration, { NoteConfig } from '../config/note.config';
|
import noteConfiguration, { NoteConfig } from '../config/note.config';
|
||||||
import { NotInDBError } from '../errors/errors';
|
import { NotInDBError } from '../errors/errors';
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
|
import { SessionState } from '../sessions/session.service';
|
||||||
import { UsersService } from '../users/users.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.
|
* This guard checks if a session is present.
|
||||||
|
@ -42,7 +49,9 @@ export class SessionGuard implements CanActivate {
|
||||||
const username = request.session?.username;
|
const username = request.session?.username;
|
||||||
if (!username) {
|
if (!username) {
|
||||||
if (this.noteConfig.guestAccess !== GuestAccess.DENY && request.session) {
|
if (this.noteConfig.guestAccess !== GuestAccess.DENY && request.session) {
|
||||||
request.session.authProvider = 'guest';
|
if (!request.session.authProviderType) {
|
||||||
|
request.session.authProviderType = ProviderType.GUEST;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.logger.debug('The user has no session.');
|
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';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class MariadbInit1725204784823 implements MigrationInterface {
|
export class Init1725266569705 implements MigrationInterface {
|
||||||
name = 'MariadbInit1725204784823';
|
name = 'Init1725266569705';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(
|
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`,
|
`CREATE TABLE \`author\` (\`id\` int NOT NULL AUTO_INCREMENT, \`color\` int NOT NULL, \`userId\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||||
);
|
);
|
||||||
await queryRunner.query(
|
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(
|
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`,
|
`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';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class Init1725203299761 implements MigrationInterface {
|
export class Init1725266697932 implements MigrationInterface {
|
||||||
name = 'Init1725203299761';
|
name = 'Init1725266697932';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(
|
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"))`,
|
`CREATE TABLE "author" ("id" SERIAL NOT NULL, "color" integer NOT NULL, "userId" integer, CONSTRAINT "PK_5a0e79799d372fe56f2f3fa6871" PRIMARY KEY ("id"))`,
|
||||||
);
|
);
|
||||||
await queryRunner.query(
|
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(
|
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"))`,
|
`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';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
export class SqliteInit1725204990810 implements MigrationInterface {
|
export class Init1725268109950 implements MigrationInterface {
|
||||||
name = 'SqliteInit1725204990810';
|
name = 'Init1725268109950';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(
|
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)`,
|
`CREATE TABLE "author" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "color" integer NOT NULL, "userId" integer)`,
|
||||||
);
|
);
|
||||||
await queryRunner.query(
|
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(
|
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"))`,
|
`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"`,
|
`ALTER TABLE "temporary_author" RENAME TO "author"`,
|
||||||
);
|
);
|
||||||
await queryRunner.query(
|
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(
|
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(`DROP TABLE "identity"`);
|
||||||
await queryRunner.query(
|
await queryRunner.query(
|
||||||
|
@ -343,10 +348,10 @@ export class SqliteInit1725204990810 implements MigrationInterface {
|
||||||
`ALTER TABLE "identity" RENAME TO "temporary_identity"`,
|
`ALTER TABLE "identity" RENAME TO "temporary_identity"`,
|
||||||
);
|
);
|
||||||
await queryRunner.query(
|
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(
|
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(`DROP TABLE "temporary_identity"`);
|
||||||
await queryRunner.query(
|
await queryRunner.query(
|
|
@ -11,6 +11,7 @@ import crypto from 'crypto';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import appConfigMock from '../config/mock/app.config.mock';
|
import appConfigMock from '../config/mock/app.config.mock';
|
||||||
|
import authConfigMock from '../config/mock/auth.config.mock';
|
||||||
import {
|
import {
|
||||||
NotInDBError,
|
NotInDBError,
|
||||||
TokenNotValidError,
|
TokenNotValidError,
|
||||||
|
@ -54,7 +55,7 @@ describe('AuthService', () => {
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfigMock],
|
load: [appConfigMock, authConfigMock],
|
||||||
}),
|
}),
|
||||||
PassportModule,
|
PassportModule,
|
||||||
UsersModule,
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -69,7 +69,7 @@ dataSource
|
||||||
Author.create(1),
|
Author.create(1),
|
||||||
)) as Author;
|
)) as Author;
|
||||||
const user = (await dataSource.manager.save(users[i])) as User;
|
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);
|
identity.passwordHash = await hashPassword(password);
|
||||||
dataSource.manager.create(Identity, identity);
|
dataSource.manager.create(Identity, identity);
|
||||||
author.user = dataSource.manager.save(user);
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -17,15 +17,37 @@ import { DatabaseType } from '../config/database-type.enum';
|
||||||
import databaseConfiguration, {
|
import databaseConfiguration, {
|
||||||
DatabaseConfig,
|
DatabaseConfig,
|
||||||
} from '../config/database.config';
|
} from '../config/database.config';
|
||||||
|
import { ProviderType } from '../identity/provider-type.enum';
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
|
import { FullUserInfoDto } from '../users/user-info.dto';
|
||||||
import { HEDGEDOC_SESSION } from '../utils/session';
|
import { HEDGEDOC_SESSION } from '../utils/session';
|
||||||
import { Username } from '../utils/username';
|
import { Username } from '../utils/username';
|
||||||
import { Session } from './session.entity';
|
import { Session } from './session.entity';
|
||||||
|
|
||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
|
/** Details about the currently used session cookie */
|
||||||
cookie: unknown;
|
cookie: unknown;
|
||||||
|
|
||||||
|
/** Contains the username if logged in completely, is undefined when not being logged in */
|
||||||
username?: Username;
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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 { BaseDto } from '../utils/base.dto.';
|
||||||
import { Username } from '../utils/username';
|
import { Username } from '../utils/username';
|
||||||
|
@ -33,11 +33,12 @@ export class UserInfoDto extends BaseDto {
|
||||||
* URL of the profile picture
|
* URL of the profile picture
|
||||||
* @example "https://hedgedoc.example.com/uploads/johnsmith.png"
|
* @example "https://hedgedoc.example.com/uploads/johnsmith.png"
|
||||||
*/
|
*/
|
||||||
@ApiProperty({
|
@ApiPropertyOptional({
|
||||||
format: 'uri',
|
format: 'uri',
|
||||||
})
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
photoUrl: string;
|
photoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -49,11 +50,21 @@ export class FullUserInfoDto extends UserInfoDto {
|
||||||
* Email address of the user
|
* Email address of the user
|
||||||
* @example "john.smith@example.com"
|
* @example "john.smith@example.com"
|
||||||
*/
|
*/
|
||||||
@ApiProperty({
|
@ApiPropertyOptional({
|
||||||
format: 'email',
|
format: 'email',
|
||||||
})
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
email: string;
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FullUserInfoWithIdDto extends FullUserInfoDto {
|
||||||
|
/**
|
||||||
|
* The user's ID
|
||||||
|
* @example 42
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserLoginInfoDto extends UserInfoDto {
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -80,12 +80,14 @@ export class User {
|
||||||
public static create(
|
public static create(
|
||||||
username: Username,
|
username: Username,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
email?: string,
|
||||||
|
photoUrl?: string,
|
||||||
): Omit<User, 'id' | 'createdAt' | 'updatedAt'> {
|
): Omit<User, 'id' | 'createdAt' | 'updatedAt'> {
|
||||||
const newUser = new User();
|
const newUser = new User();
|
||||||
newUser.username = username;
|
newUser.username = username;
|
||||||
newUser.displayName = displayName;
|
newUser.displayName = displayName;
|
||||||
newUser.photo = null;
|
newUser.photo = photoUrl ?? null;
|
||||||
newUser.email = null;
|
newUser.email = email ?? null;
|
||||||
newUser.ownedNotes = Promise.resolve([]);
|
newUser.ownedNotes = Promise.resolve([]);
|
||||||
newUser.publicAuthTokens = Promise.resolve([]);
|
newUser.publicAuthTokens = Promise.resolve([]);
|
||||||
newUser.identities = 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -9,6 +9,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import appConfigMock from '../config/mock/app.config.mock';
|
import appConfigMock from '../config/mock/app.config.mock';
|
||||||
|
import authConfigMock from '../config/mock/auth.config.mock';
|
||||||
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
|
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
|
||||||
import { LoggerModule } from '../logger/logger.module';
|
import { LoggerModule } from '../logger/logger.module';
|
||||||
import { User } from './user.entity';
|
import { User } from './user.entity';
|
||||||
|
@ -30,7 +31,7 @@ describe('UsersService', () => {
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfigMock],
|
load: [appConfigMock, authConfigMock],
|
||||||
}),
|
}),
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
],
|
],
|
||||||
|
@ -100,7 +101,7 @@ describe('UsersService', () => {
|
||||||
return user;
|
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
|
* 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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import AuthConfiguration, { AuthConfig } from '../config/auth.config';
|
||||||
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
|
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
import { Username } from '../utils/username';
|
import { Username } from '../utils/username';
|
||||||
|
@ -22,6 +24,8 @@ import { User } from './user.entity';
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: ConsoleLoggerService,
|
private readonly logger: ConsoleLoggerService,
|
||||||
|
@Inject(AuthConfiguration.KEY)
|
||||||
|
private authConfig: AuthConfig,
|
||||||
@InjectRepository(User) private userRepository: Repository<User>,
|
@InjectRepository(User) private userRepository: Repository<User>,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(UsersService.name);
|
this.logger.setContext(UsersService.name);
|
||||||
|
@ -32,11 +36,24 @@ export class UsersService {
|
||||||
* Create a new user with a given username and displayName
|
* Create a new user with a given username and displayName
|
||||||
* @param {Username} username - the username the new user shall have
|
* @param {Username} username - the username the new user shall have
|
||||||
* @param {string} displayName - the display name 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
|
* @return {User} the user
|
||||||
|
* @throws {BadRequestException} if the username contains invalid characters or is too short
|
||||||
* @throws {AlreadyInDBError} the username is already taken.
|
* @throws {AlreadyInDBError} the username is already taken.
|
||||||
*/
|
*/
|
||||||
async createUser(username: Username, displayName: string): Promise<User> {
|
async createUser(
|
||||||
const user = User.create(username, displayName);
|
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 {
|
try {
|
||||||
return await this.userRepository.save(user);
|
return await this.userRepository.save(user);
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -66,13 +83,51 @@ export class UsersService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @async
|
* @async
|
||||||
* Change the displayName of the specified user
|
* Update the given User with the given information.
|
||||||
* @param {User} user - the user to be changed
|
* Use {@code null} to clear the stored value (email or profilePicture).
|
||||||
* @param displayName - the new displayName
|
* 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> {
|
async updateUser(
|
||||||
|
user: User,
|
||||||
|
displayName?: string,
|
||||||
|
email?: string | null,
|
||||||
|
profilePicture?: string | null,
|
||||||
|
): Promise<User> {
|
||||||
|
let shouldSave = false;
|
||||||
|
if (displayName !== undefined) {
|
||||||
user.displayName = displayName;
|
user.displayName = displayName;
|
||||||
await this.userRepository.save(user);
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -27,7 +27,8 @@ export function setupSessionMiddleware(
|
||||||
name: HEDGEDOC_SESSION,
|
name: HEDGEDOC_SESSION,
|
||||||
secret: authConfig.session.secret,
|
secret: authConfig.session.secret,
|
||||||
cookie: {
|
cookie: {
|
||||||
maxAge: authConfig.session.lifetime,
|
// Handle session duration in seconds instead of ms
|
||||||
|
maxAge: authConfig.session.lifetime * 1000,
|
||||||
},
|
},
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
|
|
|
@ -41,6 +41,7 @@ describe('App', () => {
|
||||||
session: {
|
session: {
|
||||||
secret: 'secret',
|
secret: 'secret',
|
||||||
},
|
},
|
||||||
|
oidc: [],
|
||||||
})
|
})
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
|
|
|
@ -278,7 +278,7 @@ describe('Auth', () => {
|
||||||
await request(testSetup.app.getHttpServer())
|
await request(testSetup.app.getHttpServer())
|
||||||
.delete('/api/private/auth/logout')
|
.delete('/api/private/auth/logout')
|
||||||
.set('Cookie', cookie)
|
.set('Cookie', cookie)
|
||||||
.expect(204);
|
.expect(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -8,7 +8,7 @@ import request from 'supertest';
|
||||||
import { HistoryEntryImportDto } from '../../src/history/history-entry-import.dto';
|
import { HistoryEntryImportDto } from '../../src/history/history-entry-import.dto';
|
||||||
import { HistoryEntry } from '../../src/history/history-entry.entity';
|
import { HistoryEntry } from '../../src/history/history-entry.entity';
|
||||||
import { HistoryService } from '../../src/history/history.service';
|
import { HistoryService } from '../../src/history/history.service';
|
||||||
import { IdentityService } from '../../src/identity/identity.service';
|
import { LocalService } from '../../src/identity/local/local.service';
|
||||||
import { Note } from '../../src/notes/note.entity';
|
import { Note } from '../../src/notes/note.entity';
|
||||||
import { NotesService } from '../../src/notes/notes.service';
|
import { NotesService } from '../../src/notes/notes.service';
|
||||||
import { User } from '../../src/users/user.entity';
|
import { User } from '../../src/users/user.entity';
|
||||||
|
@ -18,7 +18,7 @@ import { TestSetup, TestSetupBuilder } from '../test-setup';
|
||||||
describe('History', () => {
|
describe('History', () => {
|
||||||
let testSetup: TestSetup;
|
let testSetup: TestSetup;
|
||||||
let historyService: HistoryService;
|
let historyService: HistoryService;
|
||||||
let identityService: IdentityService;
|
let localIdentityService: LocalService;
|
||||||
let user: User;
|
let user: User;
|
||||||
let note: Note;
|
let note: Note;
|
||||||
let note2: Note;
|
let note2: Note;
|
||||||
|
@ -40,9 +40,9 @@ describe('History', () => {
|
||||||
content = 'This is a test note.';
|
content = 'This is a test note.';
|
||||||
historyService = moduleRef.get(HistoryService);
|
historyService = moduleRef.get(HistoryService);
|
||||||
const userService = moduleRef.get(UsersService);
|
const userService = moduleRef.get(UsersService);
|
||||||
identityService = moduleRef.get(IdentityService);
|
localIdentityService = moduleRef.get(LocalService);
|
||||||
user = await userService.createUser(username, 'Testy');
|
user = await userService.createUser(username, 'Testy');
|
||||||
await identityService.createLocalIdentity(user, password);
|
await localIdentityService.createLocalIdentity(user, password);
|
||||||
const notesService = moduleRef.get(NotesService);
|
const notesService = moduleRef.get(NotesService);
|
||||||
note = await notesService.createNote(content, user, 'note');
|
note = await notesService.createNote(content, user, 'note');
|
||||||
note2 = await notesService.createNote(content, user, 'note2');
|
note2 = await notesService.createNote(content, user, 'note2');
|
||||||
|
|
|
@ -33,7 +33,7 @@ describe('Me', () => {
|
||||||
await testSetup.app.init();
|
await testSetup.app.init();
|
||||||
|
|
||||||
user = await testSetup.userService.createUser(username, 'Testy');
|
user = await testSetup.userService.createUser(username, 'Testy');
|
||||||
await testSetup.identityService.createLocalIdentity(user, password);
|
await testSetup.localIdentityService.createLocalIdentity(user, password);
|
||||||
|
|
||||||
content = 'This is a test note.';
|
content = 'This is a test note.';
|
||||||
alias2 = 'note2';
|
alias2 = 'note2';
|
||||||
|
|
|
@ -40,9 +40,9 @@ describe('Notes', () => {
|
||||||
const groupname1 = 'groupname1';
|
const groupname1 = 'groupname1';
|
||||||
|
|
||||||
user1 = await testSetup.userService.createUser(username1, 'Testy');
|
user1 = await testSetup.userService.createUser(username1, 'Testy');
|
||||||
await testSetup.identityService.createLocalIdentity(user1, password1);
|
await testSetup.localIdentityService.createLocalIdentity(user1, password1);
|
||||||
user2 = await testSetup.userService.createUser(username2, 'Max Mustermann');
|
user2 = await testSetup.userService.createUser(username2, 'Max Mustermann');
|
||||||
await testSetup.identityService.createLocalIdentity(user2, password2);
|
await testSetup.localIdentityService.createLocalIdentity(user2, password2);
|
||||||
|
|
||||||
group1 = await testSetup.groupService.createGroup(groupname1, 'Group 1');
|
group1 = await testSetup.groupService.createGroup(groupname1, 'Group 1');
|
||||||
|
|
||||||
|
|
|
@ -58,7 +58,7 @@ describe('Register and Login', () => {
|
||||||
expect(profile.body.authProvider).toEqual('local');
|
expect(profile.body.authProvider).toEqual('local');
|
||||||
|
|
||||||
// logout again
|
// logout again
|
||||||
await session.delete('/api/private/auth/logout').expect(204);
|
await session.delete('/api/private/auth/logout').expect(200);
|
||||||
|
|
||||||
// not allowed to request profile now
|
// not allowed to request profile now
|
||||||
await session.get('/api/private/me').expect(401);
|
await session.get('/api/private/me').expect(401);
|
||||||
|
|
|
@ -23,13 +23,13 @@ describe('Users', () => {
|
||||||
test('details for existing users can be retrieved', async () => {
|
test('details for existing users can be retrieved', async () => {
|
||||||
let response = await request
|
let response = await request
|
||||||
.agent(testSetup.app.getHttpServer())
|
.agent(testSetup.app.getHttpServer())
|
||||||
.get('/api/private/users/testuser1');
|
.get('/api/private/users/profile/testuser1');
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.username).toBe('testuser1');
|
expect(response.body.username).toBe('testuser1');
|
||||||
|
|
||||||
response = await request
|
response = await request
|
||||||
.agent(testSetup.app.getHttpServer())
|
.agent(testSetup.app.getHttpServer())
|
||||||
.get('/api/private/users/testuser2');
|
.get('/api/private/users/profile/testuser2');
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.username).toBe('testuser2');
|
expect(response.body.username).toBe('testuser2');
|
||||||
});
|
});
|
||||||
|
@ -37,7 +37,7 @@ describe('Users', () => {
|
||||||
test('details for non-existing users cannot be retrieved', async () => {
|
test('details for non-existing users cannot be retrieved', async () => {
|
||||||
const response = await request
|
const response = await request
|
||||||
.agent(testSetup.app.getHttpServer())
|
.agent(testSetup.app.getHttpServer())
|
||||||
.get('/api/private/users/i_dont_exist');
|
.get('/api/private/users/profile/i_dont_exist');
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -59,6 +59,9 @@ import { HistoryModule } from '../src/history/history.module';
|
||||||
import { HistoryService } from '../src/history/history.service';
|
import { HistoryService } from '../src/history/history.service';
|
||||||
import { IdentityModule } from '../src/identity/identity.module';
|
import { IdentityModule } from '../src/identity/identity.module';
|
||||||
import { IdentityService } from '../src/identity/identity.service';
|
import { IdentityService } from '../src/identity/identity.service';
|
||||||
|
import { LdapService } from '../src/identity/ldap/ldap.service';
|
||||||
|
import { LocalService } from '../src/identity/local/local.service';
|
||||||
|
import { OidcService } from '../src/identity/oidc/oidc.service';
|
||||||
import { ConsoleLoggerService } from '../src/logger/console-logger.service';
|
import { ConsoleLoggerService } from '../src/logger/console-logger.service';
|
||||||
import { LoggerModule } from '../src/logger/logger.module';
|
import { LoggerModule } from '../src/logger/logger.module';
|
||||||
import { MediaModule } from '../src/media/media.module';
|
import { MediaModule } from '../src/media/media.module';
|
||||||
|
@ -101,6 +104,9 @@ export class TestSetup {
|
||||||
groupService: GroupsService;
|
groupService: GroupsService;
|
||||||
configService: ConfigService;
|
configService: ConfigService;
|
||||||
identityService: IdentityService;
|
identityService: IdentityService;
|
||||||
|
localIdentityService: LocalService;
|
||||||
|
ldapService: LdapService;
|
||||||
|
oidcService: OidcService;
|
||||||
notesService: NotesService;
|
notesService: NotesService;
|
||||||
mediaService: MediaService;
|
mediaService: MediaService;
|
||||||
historyService: HistoryService;
|
historyService: HistoryService;
|
||||||
|
@ -324,6 +330,8 @@ export class TestSetupBuilder {
|
||||||
this.testSetup.moduleRef.get<ConfigService>(ConfigService);
|
this.testSetup.moduleRef.get<ConfigService>(ConfigService);
|
||||||
this.testSetup.identityService =
|
this.testSetup.identityService =
|
||||||
this.testSetup.moduleRef.get<IdentityService>(IdentityService);
|
this.testSetup.moduleRef.get<IdentityService>(IdentityService);
|
||||||
|
this.testSetup.localIdentityService =
|
||||||
|
this.testSetup.moduleRef.get<LocalService>(LocalService);
|
||||||
this.testSetup.notesService =
|
this.testSetup.notesService =
|
||||||
this.testSetup.moduleRef.get<NotesService>(NotesService);
|
this.testSetup.moduleRef.get<NotesService>(NotesService);
|
||||||
this.testSetup.mediaService =
|
this.testSetup.mediaService =
|
||||||
|
@ -342,6 +350,10 @@ export class TestSetupBuilder {
|
||||||
this.testSetup.moduleRef.get<SessionService>(SessionService);
|
this.testSetup.moduleRef.get<SessionService>(SessionService);
|
||||||
this.testSetup.revisionsService =
|
this.testSetup.revisionsService =
|
||||||
this.testSetup.moduleRef.get<RevisionsService>(RevisionsService);
|
this.testSetup.moduleRef.get<RevisionsService>(RevisionsService);
|
||||||
|
this.testSetup.ldapService =
|
||||||
|
this.testSetup.moduleRef.get<LdapService>(LdapService);
|
||||||
|
this.testSetup.oidcService =
|
||||||
|
this.testSetup.moduleRef.get<OidcService>(OidcService);
|
||||||
|
|
||||||
this.testSetup.app = this.testSetup.moduleRef.createNestApplication();
|
this.testSetup.app = this.testSetup.moduleRef.createNestApplication();
|
||||||
|
|
||||||
|
@ -389,15 +401,15 @@ export class TestSetupBuilder {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create identities for login
|
// Create identities for login
|
||||||
await this.testSetup.identityService.createLocalIdentity(
|
await this.testSetup.localIdentityService.createLocalIdentity(
|
||||||
this.testSetup.users[0],
|
this.testSetup.users[0],
|
||||||
password1,
|
password1,
|
||||||
);
|
);
|
||||||
await this.testSetup.identityService.createLocalIdentity(
|
await this.testSetup.localIdentityService.createLocalIdentity(
|
||||||
this.testSetup.users[1],
|
this.testSetup.users[1],
|
||||||
password2,
|
password2,
|
||||||
);
|
);
|
||||||
await this.testSetup.identityService.createLocalIdentity(
|
await this.testSetup.localIdentityService.createLocalIdentity(
|
||||||
this.testSetup.users[2],
|
this.testSetup.users[2],
|
||||||
password3,
|
password3,
|
||||||
);
|
);
|
||||||
|
|
|
@ -12,4 +12,5 @@ export * from './parse-url/index.js'
|
||||||
export * from './permissions/index.js'
|
export * from './permissions/index.js'
|
||||||
export * from './title-extraction/index.js'
|
export * from './title-extraction/index.js'
|
||||||
export * from './y-doc-sync/index.js'
|
export * from './y-doc-sync/index.js'
|
||||||
|
export * from './regex/index.js'
|
||||||
export * from './utils/index.js'
|
export * from './utils/index.js'
|
||||||
|
|
7
commons/src/regex/index.ts
Normal file
7
commons/src/regex/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './username.js'
|
7
commons/src/regex/username.ts
Normal file
7
commons/src/regex/username.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const REGEX_USERNAME = /^[a-z0-9-_.]{3,64}$/
|
|
@ -48,11 +48,7 @@ using one of the supported authentication methods:
|
||||||
|
|
||||||
- Username & Password (`local`)
|
- Username & Password (`local`)
|
||||||
- LDAP
|
- LDAP
|
||||||
- SAML
|
- OIDC
|
||||||
- OAuth2
|
|
||||||
- GitLab
|
|
||||||
- GitHub
|
|
||||||
- Google
|
|
||||||
|
|
||||||
The `SessionGuard`, which is added to each (appropriate) controller method of the private API,
|
The `SessionGuard`, which is added to each (appropriate) controller method of the private API,
|
||||||
checks if the provided session is still valid and provides the controller method
|
checks if the provided session is still valid and provides the controller method
|
||||||
|
|
|
@ -16,7 +16,7 @@ which contains the following information:
|
||||||
|
|
||||||
HedgeDoc 2 supports multiple authentication methods per user.
|
HedgeDoc 2 supports multiple authentication methods per user.
|
||||||
These are called *identities* and each identity is backed by an
|
These are called *identities* and each identity is backed by an
|
||||||
auth provider (like OAuth, SAML, LDAP or internal auth).
|
auth provider (like OIDC, LDAP or internal auth).
|
||||||
|
|
||||||
One of a users identities may be marked as *sync source*.
|
One of a users identities may be marked as *sync source*.
|
||||||
This identity is used to automatically update profile attributes like the
|
This identity is used to automatically update profile attributes like the
|
||||||
|
|
56
docs/content/references/config/auth/index.md
Normal file
56
docs/content/references/config/auth/index.md
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
# Authentication
|
||||||
|
|
||||||
|
HedgeDoc supports multiple authentication mechanisms that can be enabled and configured.
|
||||||
|
An authentication method is always linked to an account on the HedgeDoc instance.
|
||||||
|
However, an account can also have multiple authentication methods linked.
|
||||||
|
|
||||||
|
Each user has a unique username.
|
||||||
|
By this username, other users can invite them to their notes.
|
||||||
|
|
||||||
|
When first logging in with a new authentication method, a new account will be created.
|
||||||
|
If a user already has an account, they can link a new authentication method in their settings.
|
||||||
|
|
||||||
|
## Supported authentication methods
|
||||||
|
|
||||||
|
- [Username and password (Local account)](./local.md)
|
||||||
|
- [LDAP](./ldap.md)
|
||||||
|
- [OpenID Connect (OIDC)](./oidc.md)
|
||||||
|
|
||||||
|
While HedgeDoc provides a basic local account system, we recommend using an external
|
||||||
|
authentication mechanism for most environments.
|
||||||
|
|
||||||
|
For LDAP and OIDC you can configure multiple auth providers of that type.
|
||||||
|
You need to give each of them a unique identifier that is used in the configuration
|
||||||
|
and in the database.
|
||||||
|
The identifier should consist of only letters (`a-z`, `A-Z`), numbers (`0-9`), and dashes (`-`).
|
||||||
|
|
||||||
|
## Profile sync
|
||||||
|
|
||||||
|
A HedgeDoc account stores generic profile information like the display name of the
|
||||||
|
user and optionally a URL to a profile picture.
|
||||||
|
Depending on your configuration, users can change this information in their settings.
|
||||||
|
You can also configure HedgeDoc to sync this information from an external source like
|
||||||
|
LDAP or OIDC. In this case, changes made by the user will be overridden on login with
|
||||||
|
the external source, that is configured as sync source.
|
||||||
|
|
||||||
|
## Account merging
|
||||||
|
|
||||||
|
There's no built-in account merging in HedgeDoc. So if you registered with different
|
||||||
|
auth methods, you will have different accounts.
|
||||||
|
To manually resolve this situation, you can do the following:
|
||||||
|
|
||||||
|
1. Log in with the second account (this should be merged into the first one).
|
||||||
|
2. Visit every note, you own on that account and change the note ownership to your first account.
|
||||||
|
3. Ensure, there's nothing left anymore. Then delete the second account.
|
||||||
|
4. Log in with the first account.
|
||||||
|
5. Link the auth method of the former second account to your account in the settings.
|
||||||
|
|
||||||
|
## Common configuration
|
||||||
|
|
||||||
|
| environment variable | default | example | description |
|
||||||
|
|---------------------------------|-----------|---------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `HD_SESSION_SECRET` | (not set) | `5aaea9250828ce6942b35d170a385e74c41f1f05`, just random data | **Required.** The secret used to sign the session cookie. |
|
||||||
|
| `HD_SESSION_LIFETIME` | `1209600` | `604800`, `1209600` | The lifetime of a session in seconds. After this time without activity, a user will be logged out. |
|
||||||
|
| `HD_AUTH_ALLOW_PROFILE_EDITS` | `true` | `true`, `false` | Allow users to edit their profile information. |
|
||||||
|
| `HD_AUTH_ALLOW_CHOOSE_USERNAME` | `true` | `true`, `false` | If enabled, users may freely choose their username when signing-up via an external auth source (OIDC). Otherwise the username from the external auth source is taken. |
|
||||||
|
| `HD_AUTH_SYNC_SOURCE` | (not set) | `gitlab`, if there's an auth method (LDAP or OIDC) with the identifier `gitlab` | If enabled, the auth method with the configured identifier will update user's profile information on login. |
|
|
@ -1,22 +1,25 @@
|
||||||
# LDAP
|
# LDAP
|
||||||
|
|
||||||
HedgeDoc can use one or multiple LDAP servers to authenticate users. To do this,
|
HedgeDoc can use one or multiple LDAP servers to authenticate users. To do this, you
|
||||||
you first need to tell HedgeDoc the names of servers you want to use (`HD_AUTH_LDAPS`),
|
first need to tell HedgeDoc identifiers for the servers you want to use (`HD_AUTH_LDAP_SERVERS`).
|
||||||
and then you need to provide the configuration for those LDAP servers
|
Then you need to provide the configuration for these LDAP servers
|
||||||
depending on how you want to use them.
|
depending on how you want to use them.
|
||||||
Each of those variables will contain the given name for this LDAP server.
|
|
||||||
For example if you named your LDAP server `MY_LDAP` all variables for this server
|
Each of these variables will contain the identifier for the LDAP server.
|
||||||
will start with `HD_AUTH_LDAP_MY_LDAP`.
|
For example, if you chose the identifier `MYLDAP` for your LDAP server, all variables
|
||||||
|
for this server will start with `HD_AUTH_LDAP_MYLDAP_`.
|
||||||
|
|
||||||
|
Replace `$NAME` with the identifier of the LDAP server in the table below accordingly.
|
||||||
|
|
||||||
| environment variable | default | example | description |
|
| environment variable | default | example | description |
|
||||||
|--------------------------------------------|----------------------|----------------------------------------------------|---------------------------------------------------------------------------------------------------------------|
|
|--------------------------------------------|----------------------|----------------------------------------------------|---------------------------------------------------------------------------------------------------------------|
|
||||||
| `HD_AUTH_LDAPS` | - | `MY_LDAP` | A comma-seperated list of names of LDAP servers HedgeDoc should use. |
|
| `HD_AUTH_LDAP_SERVERS` | - | `MYLDAP` | A comma-seperated list of names of LDAP servers HedgeDoc should use. |
|
||||||
| `HD_AUTH_LDAP_$NAME_PROVIDER_NAME` | `LDAP` | `My LDAP` | The display name for the LDAP server, that is shown in the UI of HegdeDoc. |
|
| `HD_AUTH_LDAP_$NAME_PROVIDER_NAME` | `LDAP` | `My LDAP` | The display name for the LDAP server, that is shown in the UI of HegdeDoc. |
|
||||||
| `HD_AUTH_LDAP_$NAME_URL` | - | `ldaps://ldap.example.com` | The url with which the LDAP server can be accessed. |
|
| `HD_AUTH_LDAP_$NAME_URL` | - | `ldaps://ldap.example.com` | The url with which the LDAP server can be accessed. |
|
||||||
| `HD_AUTH_LDAP_$NAME_SEARCH_BASE` | - | `ou=users,dc=LDAP,dc=example,dc=com` | The LDAP search base which contains the user accounts on the LDAP server. |
|
| `HD_AUTH_LDAP_$NAME_SEARCH_BASE` | - | `ou=users,dc=LDAP,dc=example,dc=com` | The LDAP search base which contains the user accounts on the LDAP server. |
|
||||||
| `HD_AUTH_LDAP_$NAME_SEARCH_FILTER` | `(uid={{username}})` | `(&(uid={{username}})(objectClass=inetOrgPerson))` | A LDAP search filter that filters the users that should have access. |
|
| `HD_AUTH_LDAP_$NAME_SEARCH_FILTER` | `(uid={{username}})` | `(&(uid={{username}})(objectClass=inetOrgPerson))` | A LDAP search filter that filters the users that should have access. |
|
||||||
| `HD_AUTH_LDAP_$NAME_SEARCH_ATTRIBUTES` | - | `username,cn` | A comma-seperated list of attributes that the search filter from the LDAP server should access. |
|
| `HD_AUTH_LDAP_$NAME_SEARCH_ATTRIBUTES` | - | `username,cn` | A comma-seperated list of attributes that the search filter from the LDAP server should access. |
|
||||||
| `HD_AUTH_LDAP_$NAME_USERID_FIELD` | `uid` | `uid`, `uidNumber`, `sAMAccountName` | The attribute of the user account which should be used as an id for the user. |
|
| `HD_AUTH_LDAP_$NAME_USER_ID_FIELD` | `uid` | `uid`, `uidNumber`, `sAMAccountName` | The attribute of the user account which should be used as an id for the user. |
|
||||||
| `HD_AUTH_LDAP_$NAME_DISPLAY_NAME_FIELD` | `displayName` | `displayName`, `name`, `cn` | The attribute of the user account which should be used as the display name for the user. |
|
| `HD_AUTH_LDAP_$NAME_DISPLAY_NAME_FIELD` | `displayName` | `displayName`, `name`, `cn` | The attribute of the user account which should be used as the display name for the user. |
|
||||||
| `HD_AUTH_LDAP_$NAME_PROFILE_PICTURE_FIELD` | `jpegPhoto` | `jpegPhoto`, `thumbnailPhoto` | The attribute of the user account which should be used as the user image for the user. |
|
| `HD_AUTH_LDAP_$NAME_PROFILE_PICTURE_FIELD` | `jpegPhoto` | `jpegPhoto`, `thumbnailPhoto` | The attribute of the user account which should be used as the user image for the user. |
|
||||||
| `HD_AUTH_LDAP_$NAME_BIND_DN` | - | `cn=admin,dc=LDAP,dc=example,dc=com` | The dn which is used to perform the user search. If this is omitted then HedgeDoc will use an anonymous bind. |
|
| `HD_AUTH_LDAP_$NAME_BIND_DN` | - | `cn=admin,dc=LDAP,dc=example,dc=com` | The dn which is used to perform the user search. If this is omitted then HedgeDoc will use an anonymous bind. |
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
# Local
|
# Local
|
||||||
|
|
||||||
HedgeDoc provides local accounts, handled internally. This feature only provides basic
|
HedgeDoc provides local accounts, handled internally. This feature only provides basic
|
||||||
functionality, so for most environments we recommend using an external authentication mechanism,
|
functionality, so for most environments, we recommend using an external authentication mechanism,
|
||||||
which also enable more secure authentication like 2FA or WebAuthn.
|
which also enables more secure authentication like 2FA or Passkeys.
|
||||||
|
|
||||||
| environment variable | default | example | description |
|
| environment variable | default | example | description |
|
||||||
|-------------------------------------------|---------|-------------------------|-----------------------------------------------------------------------------------------------------|
|
|-------------------------------------------|---------|-------------------------|-----------------------------------------------------------------------------------------------------|
|
||||||
|
@ -16,7 +16,7 @@ The password score is calculated with [zxcvbn-ts][zxcvbn-ts-score].
|
||||||
|
|
||||||
| score | meaning | minimum number of guesses required (approximated) |
|
| score | meaning | minimum number of guesses required (approximated) |
|
||||||
|:-----:|-------------------------------------------------------------------|---------------------------------------------------|
|
|:-----:|-------------------------------------------------------------------|---------------------------------------------------|
|
||||||
| 0 | All passwords are allowed | - |
|
| 0 | All passwords with minimum 6 characters are allowed | - |
|
||||||
| 1 | Only `too guessable` passwords are disallowed | 1.000 |
|
| 1 | Only `too guessable` passwords are disallowed | 1.000 |
|
||||||
| 2 | `too guessable` and `very guessable` passwords are disallowed | 1.000.000 |
|
| 2 | `too guessable` and `very guessable` passwords are disallowed | 1.000.000 |
|
||||||
| 3 | `safely unguessable` and `very unguessable` passwords are allowed | 100.000.000 |
|
| 3 | `safely unguessable` and `very unguessable` passwords are allowed | 100.000.000 |
|
||||||
|
|
68
docs/content/references/config/auth/oidc.md
Normal file
68
docs/content/references/config/auth/oidc.md
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
# OpenID Connect (OIDC)
|
||||||
|
|
||||||
|
HedgeDoc can use one or multiple OIDC servers to authenticate users. To do this, you first need
|
||||||
|
to tell HedgeDoc identifiers for the servers you want to use (`HD_AUTH_OIDC_SERVERS`). Then you
|
||||||
|
need to provide the configuration for these OIDC servers depending on how you want to use them.
|
||||||
|
|
||||||
|
Each of these variables will contain the identifier for the OIDC server.
|
||||||
|
For example, if you chose the identifier `MYOIDC` for your OIDC server, all variables
|
||||||
|
for this server will start with `HD_AUTH_OIDC_MYOIDC_`.
|
||||||
|
|
||||||
|
Replace `$NAME` with the identifier of the OIDC server in the table below accordingly.
|
||||||
|
|
||||||
|
| environment variable | default | example | description |
|
||||||
|
|------------------------------------|------------------|--------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `HD_AUTH_OIDC_SERVERS` | - | `MYOIDC` | A comma-seperated list of identifiers of OIDC servers HedgeDoc should use. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_PROVIDER_NAME` | `OpenID Connect` | `My OIDC Single-Sign-On` | The display name for the OIDC server, that is shown in the UI of HegdeDoc. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_ISSUER` | - | `https://auth.example.com` | The base url of the OIDC issuer. It should serve a file `.well-known/openid-configuration` |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_CLIENT_ID` | - | `hd2` | The id with which HedgeDoc is registered at the OIDC server. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_CLIENT_SECRET` | - | `c3f70208375cf26700920678ec55b7df7cd75266` | The secret for the HedgeDoc application, given by the OIDC server. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_THEME` | - | `gitlab`, `google`, ... | The theme in which the button on the login page should be displayed. See below for a list of options. If not defined, a generic one will be used. |
|
||||||
|
|
||||||
|
As redirect URL you should configure
|
||||||
|
`https://hedgedoc.example.com/api/private/auth/oidc/$NAME/callback` where `$NAME`
|
||||||
|
is the identifier of the OIDC server. Remember to update the domain to your one.
|
||||||
|
|
||||||
|
You can also configure servers that only support plain OAuth2 but
|
||||||
|
no OIDC (e.g., GitHub or Discord). In this case, you need the following additional variables:
|
||||||
|
|
||||||
|
| environment variable | default | example | description |
|
||||||
|
|--------------------------------------------|----------------------|--------------------------------------------|------------------------------------------------------------------------------------------|
|
||||||
|
| `HD_AUTH_OIDC_$NAME_AUTHORIZE_URL` | - | `https://auth.example.com/oauth2/auth` | The URL to which the user should be redirected to start the OAuth2 flow. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_TOKEN_URL` | - | `https://auth.example.com/oauth2/token` | The URL to which the user should be redirected to exchange the code for an access token. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_USERINFO_URL` | - | `https://auth.example.com/oauth2/userinfo` | The URL to which the user should be redirected to get the user information. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_SCOPE` | - | `profile` | The scope that should be requested to get the user information. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_USER_ID_FIELD` | `sub` | `sub`, `id` | The unique identifier that is returned for the user from the OAuth2 provider. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_USER_ID_FIELD` | `sub` | `sub`, `id` | The unique identifier that is returned for the user from the OAuth2 provider. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_USER_NAME_FIELD` | `preferred_username` | `preferred_username`, `username` | The unique identifier that is returned for the user from the OAuth2 provider. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_DISPLAY_NAME_FIELD` | `name` | `name`, `displayName` | The field that contains the display name of the user. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_PROFILE_PICTURE_FIELD` | - | `picture`, `avatar` | The field that contains the URL to the profile picture of the user. |
|
||||||
|
| `HD_AUTH_OIDC_$NAME_EMAIL_FIELD` | `email` | `email`, `mail` | The field that contains the email address of the user. |
|
||||||
|
|
||||||
|
## Themes
|
||||||
|
|
||||||
|
To integrate the brand colors and icons of some popular OIDC providers into the login button,
|
||||||
|
you can use one of the following values:
|
||||||
|
|
||||||
|
- `google`
|
||||||
|
- `github`
|
||||||
|
- `gitlab`
|
||||||
|
- `facebook`
|
||||||
|
- `discord`
|
||||||
|
- `mastodon`
|
||||||
|
- `azure`
|
||||||
|
|
||||||
|
## Common providers
|
||||||
|
|
||||||
|
| Provider | support | issuer variable | Docs |
|
||||||
|
|-----------|-------------|---------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|
|
||||||
|
| Google | OIDC | `https://accounts.google.com` | [Google Docs](https://developers.google.com/identity/openid-connect/openid-connect) |
|
||||||
|
| GitHub | only OAuth2 | `https://github.com` | [GitHub Docs](https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps) |
|
||||||
|
| GitLab | OIDC | `https://gitlab.com` or your instance domain | [GitLab Docs](https://docs.gitlab.com/ee/integration/openid_connect_provider.html) |
|
||||||
|
| Facebook | OIDC | `https://www.facebook.com` | [Facebook Docs](https://developers.facebook.com/docs/facebook-login/overview) |
|
||||||
|
| Discord | only OAuth2 | `https://discord.com` | [Discord Docs](https://discord.com/developers/docs/topics/oauth2) |
|
||||||
|
| Azure | OIDC | `https://login.microsoftonline.com/{tenant}/v2.0`, replace accordingly | [Azure OIDC](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc) |
|
||||||
|
| Auth0 | OIDC | `https://{yourDomain}.us.auth0.com/`, replace accordingly | [Auth0 OIDC](https://auth0.com/docs/authenticate/protocols/openid-connect-protocol) |
|
||||||
|
| Keycloak | OIDC | `https://keycloak.example.com/auth/realms/{realm}`, replace accordingly | [Keycloak Docs](https://www.keycloak.org/docs/latest/server_admin/#sso-protocols) |
|
||||||
|
| Authentik | OIDC | `https://authentik.example.com/application/o/{app}/`, replace accordingly | [Authentik Docs](https://docs.goauthentik.io/docs/providers/oauth2/) |
|
||||||
|
| Authelia | OIDC | `https://authelia.example.com`, replace accordingly | [Authelia Docs](https://www.authelia.com/integration/openid-connect/introduction/) |
|
|
@ -9,8 +9,5 @@
|
||||||
| `HD_INTERNAL_API_URL` | Content of HD_BASE_URL | `http://localhost:3000` | This URL is used by the frontend to access the backend directly if it can't reach the backend using the `HD_BASE_URL` |
|
| `HD_INTERNAL_API_URL` | Content of HD_BASE_URL | `http://localhost:3000` | This URL is used by the frontend to access the backend directly if it can't reach the backend using the `HD_BASE_URL` |
|
||||||
| `HD_LOGLEVEL` | warn | | The loglevel that should be used. Options are `error`, `warn`, `info`, `debug` or `trace`. |
|
| `HD_LOGLEVEL` | warn | | The loglevel that should be used. Options are `error`, `warn`, `info`, `debug` or `trace`. |
|
||||||
| `HD_SHOW_LOG_TIMESTAMP` | true | | Specifies if a timestamp should be added to the log statements. Disabling is useful for extern log management (systemd etc.) |
|
| `HD_SHOW_LOG_TIMESTAMP` | true | | Specifies if a timestamp should be added to the log statements. Disabling is useful for extern log management (systemd etc.) |
|
||||||
| `HD_FORBIDDEN_NOTE_IDS` | - | `notAllowed,alsoNotAllowed` | A list of note ids (separated by `,`), that are not allowed to be created or requested by anyone. |
|
|
||||||
| `HD_MAX_DOCUMENT_LENGTH` | 100000 | | The maximum length of any one document. Changes to this will impact performance for your users. |
|
|
||||||
| `HD_PERSIST_INTERVAL` | 10 | `0`, `5`, `10`, `20` | The time interval in **minutes** for the periodic note revision creation during realtime editing. `0` deactivates the periodic note revision creation. |
|
|
||||||
|
|
||||||
[faq-entry]: ../../faq/index.md#why-should-i-want-to-run-my-renderer-on-a-different-sub-domain
|
[faq-entry]: ../../faq/index.md#why-should-i-want-to-run-my-renderer-on-a-different-sub-domain
|
||||||
|
|
|
@ -4,9 +4,9 @@ HedgeDoc can be configured via environment variables either directly or via an `
|
||||||
|
|
||||||
## The `.env` file
|
## The `.env` file
|
||||||
|
|
||||||
The `.env` file should be in the working directory of the backend and contains key-value pairs of
|
The `.env` file should be in the root directory of the HedgeDoc application and
|
||||||
environment variables and their corresponding value.
|
contains key-value pairs of environment variables and their corresponding value.
|
||||||
In the official Docker container this is `/usr/src/app/backend/`
|
In the official Docker container this is `/usr/src/app/.env`
|
||||||
This can for example look like this:
|
This can for example look like this:
|
||||||
|
|
||||||
<!-- markdownlint-disable proper-names -->
|
<!-- markdownlint-disable proper-names -->
|
||||||
|
|
|
@ -7,3 +7,4 @@
|
||||||
| `HD_GUEST_ACCESS` | `write` | `deny`, `read`, `write`, `create` | Defines the maximum access level for guest users to the instance. If guest access is set lower than the "everyone" permission of a note then the note permission will be overridden. |
|
| `HD_GUEST_ACCESS` | `write` | `deny`, `read`, `write`, `create` | Defines the maximum access level for guest users to the instance. If guest access is set lower than the "everyone" permission of a note then the note permission will be overridden. |
|
||||||
| `HD_PERMISSION_DEFAULT_LOGGED_IN` | `write` | `none`, `read`, `write` | The default permission for the "logged-in" group that is set on new notes. |
|
| `HD_PERMISSION_DEFAULT_LOGGED_IN` | `write` | `none`, `read`, `write` | The default permission for the "logged-in" group that is set on new notes. |
|
||||||
| `HD_PERMISSION_DEFAULT_EVERYONE` | `read` | `none`, `read`, `write` | The default permission for the "everyone" group (logged-in & guest users), that is set on new notes created by logged-in users. Notes created by guests always set this to "write". |
|
| `HD_PERMISSION_DEFAULT_EVERYONE` | `read` | `none`, `read`, `write` | The default permission for the "everyone" group (logged-in & guest users), that is set on new notes created by logged-in users. Notes created by guests always set this to "write". |
|
||||||
|
| `HD_PERSIST_INTERVAL` | 10 | `0`, `5`, `10`, `20` | The time interval in **minutes** for the periodic note revision creation during realtime editing. `0` deactivates the periodic note revision creation. |
|
||||||
|
|
|
@ -41,8 +41,10 @@ nav:
|
||||||
- Notes: references/config/notes.md
|
- Notes: references/config/notes.md
|
||||||
- Database: references/config/database.md
|
- Database: references/config/database.md
|
||||||
- Authentication:
|
- Authentication:
|
||||||
|
- Overview: references/config/auth/index.md
|
||||||
- 'Local accounts': references/config/auth/local.md
|
- 'Local accounts': references/config/auth/local.md
|
||||||
- LDAP: references/config/auth/ldap.md
|
- LDAP: references/config/auth/ldap.md
|
||||||
|
- 'OpenID Connect (OIDC)': references/config/auth/oidc.md
|
||||||
- Customization: references/config/customization.md
|
- Customization: references/config/customization.md
|
||||||
- Media Backends:
|
- Media Backends:
|
||||||
- Azure: references/config/media/azure.md
|
- Azure: references/config/media/azure.md
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -76,14 +76,17 @@ describe('When logged-out ', () => {
|
||||||
it('sign-in button points to auth-provider', () => {
|
it('sign-in button points to auth-provider', () => {
|
||||||
initLoggedOutTestWithCustomAuthProviders(cy, [
|
initLoggedOutTestWithCustomAuthProviders(cy, [
|
||||||
{
|
{
|
||||||
type: AuthProviderType.GITHUB
|
type: AuthProviderType.OIDC,
|
||||||
|
identifier: 'github',
|
||||||
|
providerName: 'GitHub',
|
||||||
|
theme: 'github'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
cy.getByCypressId('sign-in-button')
|
cy.getByCypressId('sign-in-button')
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.parent()
|
.parent()
|
||||||
// The absolute URL is used because it is defined as API base URL absolute.
|
// The absolute URL is used because it is defined as API base URL absolute.
|
||||||
.should('have.attr', 'href', '/auth/github')
|
.should('have.attr', 'href', '/api/private/auth/oidc/github')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -91,10 +94,16 @@ describe('When logged-out ', () => {
|
||||||
it('sign-in button points to login route', () => {
|
it('sign-in button points to login route', () => {
|
||||||
initLoggedOutTestWithCustomAuthProviders(cy, [
|
initLoggedOutTestWithCustomAuthProviders(cy, [
|
||||||
{
|
{
|
||||||
type: AuthProviderType.GITHUB
|
type: AuthProviderType.OIDC,
|
||||||
|
identifier: 'github',
|
||||||
|
providerName: 'GitHub',
|
||||||
|
theme: 'github'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: AuthProviderType.GOOGLE
|
type: AuthProviderType.OIDC,
|
||||||
|
identifier: 'gitlab',
|
||||||
|
providerName: 'GitLab',
|
||||||
|
theme: 'gitlab'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
cy.getByCypressId('sign-in-button')
|
cy.getByCypressId('sign-in-button')
|
||||||
|
@ -108,7 +117,10 @@ describe('When logged-out ', () => {
|
||||||
it('sign-in button points to login route', () => {
|
it('sign-in button points to login route', () => {
|
||||||
initLoggedOutTestWithCustomAuthProviders(cy, [
|
initLoggedOutTestWithCustomAuthProviders(cy, [
|
||||||
{
|
{
|
||||||
type: AuthProviderType.GITHUB
|
type: AuthProviderType.OIDC,
|
||||||
|
identifier: 'github',
|
||||||
|
providerName: 'GitHub',
|
||||||
|
theme: 'github'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: AuthProviderType.LOCAL
|
type: AuthProviderType.LOCAL
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -21,12 +21,6 @@ export const branding = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authProviders = [
|
export const authProviders = [
|
||||||
{
|
|
||||||
type: AuthProviderType.GITHUB
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: AuthProviderType.GOOGLE
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: AuthProviderType.LOCAL
|
type: AuthProviderType.LOCAL
|
||||||
},
|
},
|
||||||
|
@ -36,24 +30,16 @@ export const authProviders = [
|
||||||
providerName: 'Test LDAP'
|
providerName: 'Test LDAP'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: AuthProviderType.OAUTH2,
|
type: AuthProviderType.OIDC,
|
||||||
identifier: 'test-oauth2',
|
identifier: 'test-oidc',
|
||||||
providerName: 'Test OAuth2'
|
providerName: 'Test OIDC'
|
||||||
},
|
|
||||||
{
|
|
||||||
type: AuthProviderType.SAML,
|
|
||||||
identifier: 'test-saml',
|
|
||||||
providerName: 'Test SAML'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: AuthProviderType.GITLAB,
|
|
||||||
identifier: 'test-gitlab',
|
|
||||||
providerName: 'Test GitLab'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
allowRegister: true,
|
allowRegister: true,
|
||||||
|
allowProfileEdits: true,
|
||||||
|
allowChooseUsername: true,
|
||||||
guestAccess: 'write',
|
guestAccess: 'write',
|
||||||
authProviders: authProviders,
|
authProviders: authProviders,
|
||||||
branding: branding,
|
branding: branding,
|
||||||
|
|
|
@ -169,6 +169,10 @@
|
||||||
"successTitle": "Password changed",
|
"successTitle": "Password changed",
|
||||||
"successText": "Your password has been changed successfully."
|
"successText": "Your password has been changed successfully."
|
||||||
},
|
},
|
||||||
|
"selectProfilePicture": {
|
||||||
|
"title": "Select profile picture",
|
||||||
|
"info": "Your profile picture is publicly visible. Depending on your auth provider, you might have more or less choices here."
|
||||||
|
},
|
||||||
"changeDisplayNameFailed": "There was an error changing your display name.",
|
"changeDisplayNameFailed": "There was an error changing your display name.",
|
||||||
"accountManagement": "Account management",
|
"accountManagement": "Account management",
|
||||||
"deleteUser": "Delete user",
|
"deleteUser": "Delete user",
|
||||||
|
@ -608,6 +612,13 @@
|
||||||
"usernameExisting": "There is already an account with this username.",
|
"usernameExisting": "There is already an account with this username.",
|
||||||
"other": "There was an error while registering your account. Just try it again."
|
"other": "There was an error while registering your account. Just try it again."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"welcome": {
|
||||||
|
"title": "Welcome, {{name}}!",
|
||||||
|
"titleFallback": "Welcome!",
|
||||||
|
"description": "It seems this is the first time you logged in to this instance. Please confirm your information to continue and login. This needs to be done only once.",
|
||||||
|
"error": "There was an error creating your user account. Please try again.",
|
||||||
|
"cancelError": "There was an error with the process. If this persists, try to clear your cookies, reload and try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motd": {
|
"motd": {
|
||||||
|
|
|
@ -1,15 +1,32 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
|
import type { LogoutResponseDto, UsernameCheckDto, UsernameCheckResponseDto } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests to log out the current user.
|
* Requests to log out the current user.
|
||||||
*
|
*
|
||||||
* @throws {Error} if logout is not possible.
|
* @throws {Error} if logout is not possible.
|
||||||
*/
|
*/
|
||||||
export const doLogout = async (): Promise<void> => {
|
export const doLogout = async (): Promise<LogoutResponseDto> => {
|
||||||
await new DeleteApiRequestBuilder('auth/logout').sendRequest()
|
const response = await new DeleteApiRequestBuilder<LogoutResponseDto>('auth/logout').sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests to check if a username is available.
|
||||||
|
*
|
||||||
|
* @param username The username to check.
|
||||||
|
* @returns {boolean} whether the username is available or not.
|
||||||
|
*/
|
||||||
|
export const checkUsernameAvailability = async (username: string): Promise<boolean> => {
|
||||||
|
const response = await new PostApiRequestBuilder<UsernameCheckResponseDto, UsernameCheckDto>('users/check')
|
||||||
|
.withJsonBody({ username })
|
||||||
|
.sendRequest()
|
||||||
|
const json = await response.asParsedJsonObject()
|
||||||
|
return json.usernameAvailable
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||||
import type { LoginDto } from './types'
|
import type { LdapLoginResponseDto, LoginDto } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests to log in a user via LDAP credentials.
|
* Requests to log in a user via LDAP credentials.
|
||||||
|
@ -14,11 +14,16 @@ import type { LoginDto } from './types'
|
||||||
* @param password The password of the user.
|
* @param password The password of the user.
|
||||||
* @throws {Error} when the api request wasn't successfull
|
* @throws {Error} when the api request wasn't successfull
|
||||||
*/
|
*/
|
||||||
export const doLdapLogin = async (provider: string, username: string, password: string): Promise<void> => {
|
export const doLdapLogin = async (
|
||||||
await new PostApiRequestBuilder<void, LoginDto>('auth/ldap/' + provider)
|
provider: string,
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<LdapLoginResponseDto> => {
|
||||||
|
const response = await new PostApiRequestBuilder<LdapLoginResponseDto, LoginDto>(`auth/ldap/${provider}/login`)
|
||||||
.withJsonBody({
|
.withJsonBody({
|
||||||
username: username,
|
username: username,
|
||||||
password: password
|
password: password
|
||||||
})
|
})
|
||||||
.sendRequest()
|
.sendRequest()
|
||||||
|
return await response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
36
frontend/src/api/auth/pending-user.ts
Normal file
36
frontend/src/api/auth/pending-user.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { FullUserInfo } from '../users/types'
|
||||||
|
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||||
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
|
import type { PendingUserConfirmDto } from './types'
|
||||||
|
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the pending user information.
|
||||||
|
* @returns The pending user information.
|
||||||
|
*/
|
||||||
|
export const getPendingUserInfo = async (): Promise<Partial<FullUserInfo>> => {
|
||||||
|
const response = await new GetApiRequestBuilder<Partial<FullUserInfo>>('auth/pending-user').sendRequest()
|
||||||
|
return response.asParsedJsonObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the pending user.
|
||||||
|
*/
|
||||||
|
export const cancelPendingUser = async (): Promise<void> => {
|
||||||
|
await new DeleteApiRequestBuilder<void>('auth/pending-user').sendRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirms the pending user with updated user information.
|
||||||
|
* @param updatedUserInfo The updated user information.
|
||||||
|
*/
|
||||||
|
export const confirmPendingUser = async (updatedUserInfo: PendingUserConfirmDto): Promise<void> => {
|
||||||
|
await new PutApiRequestBuilder<void, PendingUserConfirmDto>('auth/pending-user')
|
||||||
|
.withJsonBody(updatedUserInfo)
|
||||||
|
.sendRequest()
|
||||||
|
}
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -19,3 +19,25 @@ export interface ChangePasswordDto {
|
||||||
currentPassword: string
|
currentPassword: string
|
||||||
newPassword: string
|
newPassword: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LogoutResponseDto {
|
||||||
|
redirect: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsernameCheckDto {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsernameCheckResponseDto {
|
||||||
|
usernameAvailable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingUserConfirmDto {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
profilePicture: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LdapLoginResponseDto {
|
||||||
|
newUser: boolean
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface FrontendConfig {
|
export interface FrontendConfig {
|
||||||
allowRegister: boolean
|
allowRegister: boolean
|
||||||
|
allowProfileEdits: boolean
|
||||||
|
allowChooseUsername: boolean
|
||||||
authProviders: AuthProvider[]
|
authProviders: AuthProvider[]
|
||||||
branding: BrandingConfig
|
branding: BrandingConfig
|
||||||
guestAccess: GuestAccessLevel
|
guestAccess: GuestAccessLevel
|
||||||
|
@ -24,38 +26,22 @@ export enum GuestAccessLevel {
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AuthProviderType {
|
export enum AuthProviderType {
|
||||||
GITHUB = 'github',
|
OIDC = 'oidc',
|
||||||
GOOGLE = 'google',
|
|
||||||
GITLAB = 'gitlab',
|
|
||||||
OAUTH2 = 'oauth2',
|
|
||||||
LDAP = 'ldap',
|
LDAP = 'ldap',
|
||||||
SAML = 'saml',
|
|
||||||
LOCAL = 'local'
|
LOCAL = 'local'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthProviderTypeWithCustomName =
|
export type AuthProviderTypeWithCustomName = AuthProviderType.LDAP | AuthProviderType.OIDC
|
||||||
| AuthProviderType.GITLAB
|
|
||||||
| AuthProviderType.OAUTH2
|
|
||||||
| AuthProviderType.LDAP
|
|
||||||
| AuthProviderType.SAML
|
|
||||||
|
|
||||||
export type AuthProviderTypeWithoutCustomName =
|
export type AuthProviderTypeWithoutCustomName = AuthProviderType.LOCAL
|
||||||
| AuthProviderType.GITHUB
|
|
||||||
| AuthProviderType.GOOGLE
|
|
||||||
| AuthProviderType.LOCAL
|
|
||||||
|
|
||||||
export const authProviderTypeOneClick = [
|
export const authProviderTypeOneClick = [AuthProviderType.OIDC]
|
||||||
AuthProviderType.GITHUB,
|
|
||||||
AuthProviderType.GITLAB,
|
|
||||||
AuthProviderType.GOOGLE,
|
|
||||||
AuthProviderType.OAUTH2,
|
|
||||||
AuthProviderType.SAML
|
|
||||||
]
|
|
||||||
|
|
||||||
export interface AuthProviderWithCustomName {
|
export interface AuthProviderWithCustomName {
|
||||||
type: AuthProviderTypeWithCustomName
|
type: AuthProviderTypeWithCustomName
|
||||||
identifier: string
|
identifier: string
|
||||||
providerName: string
|
providerName: string
|
||||||
|
theme?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthProviderWithoutCustomName {
|
export interface AuthProviderWithoutCustomName {
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -13,7 +13,7 @@ import type { UserInfo } from './types'
|
||||||
* @return Metadata about the requested user.
|
* @return Metadata about the requested user.
|
||||||
* @throws {Error} when the api request wasn't successful.
|
* @throws {Error} when the api request wasn't successful.
|
||||||
*/
|
*/
|
||||||
export const getUser = async (username: string): Promise<UserInfo> => {
|
export const getUserInfo = async (username: string): Promise<UserInfo> => {
|
||||||
const response = await new GetApiRequestBuilder<UserInfo>('users/' + username).sendRequest()
|
const response = await new GetApiRequestBuilder<UserInfo>(`users/profile/${username}`).sendRequest()
|
||||||
return response.asParsedJsonObject()
|
return response.asParsedJsonObject()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -7,5 +7,9 @@
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
photoUrl: string
|
photoUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullUserInfo extends UserInfo {
|
||||||
|
email: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,20 +7,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { NextPage } from 'next'
|
import type { NextPage } from 'next'
|
||||||
import { EditorToRendererCommunicatorContextProvider } from '../../../components/editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
|
||||||
import { HedgeDocLogoVertical } from '../../../components/common/hedge-doc-logo/hedge-doc-logo-vertical'
|
|
||||||
import { LogoSize } from '../../../components/common/hedge-doc-logo/logo-size'
|
|
||||||
import { Trans } from 'react-i18next'
|
|
||||||
import { CustomBranding } from '../../../components/common/custom-branding/custom-branding'
|
|
||||||
import { IntroCustomContent } from '../../../components/intro-page/intro-custom-content'
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { RedirectToParamOrHistory } from '../../../components/login-page/redirect-to-param-or-history'
|
import { RedirectToParamOrHistory } from '../../../components/login-page/redirect-to-param-or-history'
|
||||||
import { Col, Container, Row } from 'react-bootstrap'
|
|
||||||
import { LocalLoginCard } from '../../../components/login-page/local-login/local-login-card'
|
import { LocalLoginCard } from '../../../components/login-page/local-login/local-login-card'
|
||||||
import { LdapLoginCards } from '../../../components/login-page/ldap/ldap-login-cards'
|
import { LdapLoginCards } from '../../../components/login-page/ldap/ldap-login-cards'
|
||||||
import { OneClickLoginCard } from '../../../components/login-page/one-click/one-click-login-card'
|
import { OneClickLoginCard } from '../../../components/login-page/one-click/one-click-login-card'
|
||||||
import { GuestCard } from '../../../components/login-page/guest/guest-card'
|
import { GuestCard } from '../../../components/login-page/guest/guest-card'
|
||||||
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
|
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
|
||||||
|
import { LoginLayout } from '../../../components/layout/login-layout'
|
||||||
|
|
||||||
const LoginPage: NextPage = () => {
|
const LoginPage: NextPage = () => {
|
||||||
const userLoggedIn = useIsLoggedIn()
|
const userLoggedIn = useIsLoggedIn()
|
||||||
|
@ -30,30 +24,12 @@ const LoginPage: NextPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<LoginLayout>
|
||||||
<Row>
|
|
||||||
<Col xs={8}>
|
|
||||||
<EditorToRendererCommunicatorContextProvider>
|
|
||||||
<div className={'d-flex flex-column align-items-center mt-3'}>
|
|
||||||
<HedgeDocLogoVertical size={LogoSize.BIG} autoTextColor={true} />
|
|
||||||
<h5>
|
|
||||||
<Trans i18nKey='app.slogan' />
|
|
||||||
</h5>
|
|
||||||
<div className={'mb-5'}>
|
|
||||||
<CustomBranding />
|
|
||||||
</div>
|
|
||||||
<IntroCustomContent />
|
|
||||||
</div>
|
|
||||||
</EditorToRendererCommunicatorContextProvider>
|
|
||||||
</Col>
|
|
||||||
<Col xs={4} className={'pt-3 d-flex gap-3 flex-column'}>
|
|
||||||
<GuestCard />
|
<GuestCard />
|
||||||
<LocalLoginCard />
|
<LocalLoginCard />
|
||||||
<LdapLoginCards />
|
<LdapLoginCards />
|
||||||
<OneClickLoginCard />
|
<OneClickLoginCard />
|
||||||
</Col>
|
</LoginLayout>
|
||||||
</Row>
|
|
||||||
</Container>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
33
frontend/src/app/(editor)/new-user/page.tsx
Normal file
33
frontend/src/app/(editor)/new-user/page.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
'use client'
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { NextPage } from 'next'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import React from 'react'
|
||||||
|
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
|
||||||
|
import { RedirectToParamOrHistory } from '../../../components/login-page/redirect-to-param-or-history'
|
||||||
|
import { NewUserCard } from '../../../components/login-page/new-user/new-user-card'
|
||||||
|
import { LoginLayout } from '../../../components/layout/login-layout'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the page where users pick a username when they first log in via SSO.
|
||||||
|
*/
|
||||||
|
const NewUserPage: NextPage = () => {
|
||||||
|
useTranslation()
|
||||||
|
const userLoggedIn = useIsLoggedIn()
|
||||||
|
|
||||||
|
if (userLoggedIn) {
|
||||||
|
return <RedirectToParamOrHistory />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoginLayout>
|
||||||
|
<NewUserCard />
|
||||||
|
</LoginLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NewUserPage
|
|
@ -1,16 +1,18 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
|
||||||
import type { CommonFieldProps } from './fields'
|
import type { CommonFieldProps } from './fields'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useEffect, useMemo } from 'react'
|
||||||
import { Form } from 'react-bootstrap'
|
import { Form } from 'react-bootstrap'
|
||||||
import { Trans } from 'react-i18next'
|
import { Trans } from 'react-i18next'
|
||||||
|
import { useFrontendConfig } from '../frontend-config-context/use-frontend-config'
|
||||||
|
|
||||||
interface DisplayNameFieldProps extends CommonFieldProps {
|
interface DisplayNameFieldProps extends CommonFieldProps {
|
||||||
initialValue?: string
|
initialValue?: string
|
||||||
|
onValidityChange?: (valid: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -19,10 +21,21 @@ interface DisplayNameFieldProps extends CommonFieldProps {
|
||||||
* @param onChange Hook that is called when the entered display name changes.
|
* @param onChange Hook that is called when the entered display name changes.
|
||||||
* @param value The currently entered display name.
|
* @param value The currently entered display name.
|
||||||
* @param initialValue The initial input field value.
|
* @param initialValue The initial input field value.
|
||||||
|
* @param onValidityChange Callback that is called when the validity of the field changes.
|
||||||
*/
|
*/
|
||||||
export const DisplayNameField: React.FC<DisplayNameFieldProps> = ({ onChange, value, initialValue }) => {
|
export const DisplayNameField: React.FC<DisplayNameFieldProps> = ({
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
initialValue,
|
||||||
|
onValidityChange
|
||||||
|
}) => {
|
||||||
const isValid = useMemo(() => value.trim() !== '' && value !== initialValue, [value, initialValue])
|
const isValid = useMemo(() => value.trim() !== '' && value !== initialValue, [value, initialValue])
|
||||||
const placeholderText = useTranslatedText('profile.displayName')
|
const placeholderText = useTranslatedText('profile.displayName')
|
||||||
|
const profileEditsAllowed = useFrontendConfig().allowProfileEdits
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onValidityChange?.(isValid)
|
||||||
|
}, [isValid, onValidityChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
|
@ -37,6 +50,7 @@ export const DisplayNameField: React.FC<DisplayNameFieldProps> = ({ onChange, va
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder={placeholderText}
|
placeholder={placeholderText}
|
||||||
autoComplete='name'
|
autoComplete='name'
|
||||||
|
disabled={!profileEditsAllowed}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Form.Text>
|
<Form.Text>
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { ChangeEvent } from 'react'
|
import type { ChangeEventHandler } from 'react'
|
||||||
|
|
||||||
export interface CommonFieldProps {
|
export interface CommonFieldProps<ValueType = undefined> {
|
||||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void
|
onChange: ValueType extends undefined ? ChangeEventHandler : (set: ValueType) => void
|
||||||
value: string
|
value: ValueType extends undefined ? string : ValueType
|
||||||
hasError?: boolean
|
hasError?: boolean
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { Trans } from 'react-i18next'
|
||||||
* @param value The currently entered password.
|
* @param value The currently entered password.
|
||||||
*/
|
*/
|
||||||
export const NewPasswordField: React.FC<CommonFieldProps> = ({ onChange, value, hasError = false }) => {
|
export const NewPasswordField: React.FC<CommonFieldProps> = ({ onChange, value, hasError = false }) => {
|
||||||
const isValid = useMemo(() => value.trim() !== '', [value])
|
const isValid = useMemo(() => value.length >= 6, [value])
|
||||||
|
|
||||||
const placeholderText = useTranslatedText('login.auth.password')
|
const placeholderText = useTranslatedText('login.auth.password')
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import type { CommonFieldProps } from './fields'
|
||||||
|
import { Form } from 'react-bootstrap'
|
||||||
|
import { Trans } from 'react-i18next'
|
||||||
|
import { useAvatarUrl } from '../user-avatar/hooks/use-avatar-url'
|
||||||
|
import { useFrontendConfig } from '../frontend-config-context/use-frontend-config'
|
||||||
|
|
||||||
|
export enum ProfilePictureChoice {
|
||||||
|
PROVIDER,
|
||||||
|
FALLBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfilePictureSelectFieldProps extends CommonFieldProps<ProfilePictureChoice> {
|
||||||
|
onChange: (choice: ProfilePictureChoice) => void
|
||||||
|
value: ProfilePictureChoice
|
||||||
|
pictureUrl?: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A field to select the profile picture.
|
||||||
|
* @param onChange The callback to call when the value changes.
|
||||||
|
* @param pictureUrl The URL of the picture provided by the identity provider.
|
||||||
|
* @param username The username of the user.
|
||||||
|
* @param value The current value of the field.
|
||||||
|
*/
|
||||||
|
export const ProfilePictureSelectField: React.FC<ProfilePictureSelectFieldProps> = ({
|
||||||
|
onChange,
|
||||||
|
pictureUrl,
|
||||||
|
username,
|
||||||
|
value
|
||||||
|
}) => {
|
||||||
|
const fallbackUrl = useAvatarUrl(undefined, username)
|
||||||
|
const profileEditsAllowed = useFrontendConfig().allowProfileEdits
|
||||||
|
const onSetProviderPicture = useCallback(() => {
|
||||||
|
if (value !== ProfilePictureChoice.PROVIDER) {
|
||||||
|
onChange(ProfilePictureChoice.PROVIDER)
|
||||||
|
}
|
||||||
|
}, [onChange, value])
|
||||||
|
const onSetFallbackPicture = useCallback(() => {
|
||||||
|
if (value !== ProfilePictureChoice.FALLBACK) {
|
||||||
|
onChange(ProfilePictureChoice.FALLBACK)
|
||||||
|
}
|
||||||
|
}, [onChange, value])
|
||||||
|
|
||||||
|
if (!profileEditsAllowed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>
|
||||||
|
<Trans i18nKey='profile.selectProfilePicture.title' />
|
||||||
|
</Form.Label>
|
||||||
|
{pictureUrl && (
|
||||||
|
<Form.Check className={'d-flex gap-2 align-items-center mb-3'} type='radio'>
|
||||||
|
<Form.Check.Input
|
||||||
|
type={'radio'}
|
||||||
|
checked={value === ProfilePictureChoice.PROVIDER}
|
||||||
|
onChange={onSetProviderPicture}
|
||||||
|
/>
|
||||||
|
<Form.Check.Label>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={pictureUrl} alt={'Profile picture provided by the identity provider'} height={48} width={48} />
|
||||||
|
</Form.Check.Label>
|
||||||
|
</Form.Check>
|
||||||
|
)}
|
||||||
|
<Form.Check className={'d-flex gap-2 align-items-center'} type='radio'>
|
||||||
|
<Form.Check.Input
|
||||||
|
type='radio'
|
||||||
|
checked={value === ProfilePictureChoice.FALLBACK}
|
||||||
|
onChange={onSetFallbackPicture}
|
||||||
|
/>
|
||||||
|
<Form.Check.Label>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img alt={'Fallback profile picture'} src={fallbackUrl} height={48} width={48} />
|
||||||
|
</Form.Check.Label>
|
||||||
|
</Form.Check>
|
||||||
|
<Form.Text>
|
||||||
|
<Trans i18nKey='profile.selectProfilePicture.info' />
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -11,6 +11,7 @@ import { Form } from 'react-bootstrap'
|
||||||
export interface UsernameFieldProps extends CommonFieldProps {
|
export interface UsernameFieldProps extends CommonFieldProps {
|
||||||
isInvalid?: boolean
|
isInvalid?: boolean
|
||||||
isValid?: boolean
|
isValid?: boolean
|
||||||
|
onValidityChange?: (valid: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -21,7 +22,7 @@ export interface UsernameFieldProps extends CommonFieldProps {
|
||||||
* @param isValid Is a valid field or not
|
* @param isValid Is a valid field or not
|
||||||
* @param isInvalid Adds error style to label
|
* @param isInvalid Adds error style to label
|
||||||
*/
|
*/
|
||||||
export const UsernameField: React.FC<UsernameFieldProps> = ({ onChange, value, isValid, isInvalid }) => {
|
export const UsernameField: React.FC<UsernameFieldProps> = ({ onChange, value, isValid, isInvalid, disabled }) => {
|
||||||
const placeholderText = useTranslatedText('login.auth.username')
|
const placeholderText = useTranslatedText('login.auth.username')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -29,10 +30,12 @@ export const UsernameField: React.FC<UsernameFieldProps> = ({ onChange, value, i
|
||||||
type='text'
|
type='text'
|
||||||
size='sm'
|
size='sm'
|
||||||
value={value}
|
value={value}
|
||||||
|
maxLength={64}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
isInvalid={isInvalid}
|
isInvalid={isInvalid}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder={placeholderText}
|
placeholder={placeholderText}
|
||||||
|
disabled={disabled}
|
||||||
autoComplete='username'
|
autoComplete='username'
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
required
|
required
|
||||||
|
|
|
@ -1,29 +1,77 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { UsernameFieldProps } from './username-field'
|
import type { UsernameFieldProps } from './username-field'
|
||||||
import { UsernameField } from './username-field'
|
import { UsernameField } from './username-field'
|
||||||
import React from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Form } from 'react-bootstrap'
|
import { Form } from 'react-bootstrap'
|
||||||
import { Trans } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
import { useDebounce } from 'react-use'
|
||||||
|
import { checkUsernameAvailability } from '../../../api/auth'
|
||||||
|
import { Logger } from '../../../utils/logger'
|
||||||
|
import { useFrontendConfig } from '../frontend-config-context/use-frontend-config'
|
||||||
|
import { REGEX_USERNAME } from '@hedgedoc/commons'
|
||||||
|
|
||||||
|
const logger = new Logger('UsernameLabelField')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps and contains label and info for UsernameField
|
* Wraps and contains label and info for UsernameField
|
||||||
*
|
*
|
||||||
* @param onChange Callback that is called when the entered username changes.
|
|
||||||
* @param value The currently entered username.
|
* @param value The currently entered username.
|
||||||
* @param isValid Is a valid field or not
|
* @param onValidityChange Callback that is called when the validity of the field changes.
|
||||||
* @param isInvalid Adds error style to label
|
* @param props Additional props for the UsernameField.
|
||||||
*/
|
*/
|
||||||
export const UsernameLabelField: React.FC<UsernameFieldProps> = (props) => {
|
export const UsernameLabelField: React.FC<UsernameFieldProps> = ({ value, onValidityChange, ...props }) => {
|
||||||
|
useTranslation()
|
||||||
|
const [usernameValid, setUsernameValid] = useState(false)
|
||||||
|
const [usernameInvalid, setUsernameInvalid] = useState(false)
|
||||||
|
const usernameChoosingAllowed = useFrontendConfig().allowChooseUsername
|
||||||
|
|
||||||
|
useDebounce(
|
||||||
|
() => {
|
||||||
|
if (value === '') {
|
||||||
|
setUsernameValid(false)
|
||||||
|
setUsernameInvalid(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!REGEX_USERNAME.test(value)) {
|
||||||
|
setUsernameValid(false)
|
||||||
|
setUsernameInvalid(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkUsernameAvailability(value)
|
||||||
|
.then((available) => {
|
||||||
|
setUsernameValid(available)
|
||||||
|
setUsernameInvalid(!available)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error('Failed to check username availability', error)
|
||||||
|
setUsernameValid(false)
|
||||||
|
setUsernameInvalid(false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
[value]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onValidityChange?.(usernameValid && !usernameInvalid)
|
||||||
|
}, [usernameValid, usernameInvalid, onValidityChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Label>
|
<Form.Label>
|
||||||
<Trans i18nKey='login.auth.username' />
|
<Trans i18nKey='login.auth.username' />
|
||||||
</Form.Label>
|
</Form.Label>
|
||||||
<UsernameField {...props} />
|
<UsernameField
|
||||||
|
value={value}
|
||||||
|
{...props}
|
||||||
|
disabled={!usernameChoosingAllowed}
|
||||||
|
isInvalid={usernameInvalid}
|
||||||
|
isValid={usernameValid}
|
||||||
|
/>
|
||||||
<Form.Text>
|
<Form.Text>
|
||||||
<Trans i18nKey='login.register.usernameInfo' />
|
<Trans i18nKey='login.register.usernameInfo' />
|
||||||
</Form.Text>
|
</Form.Text>
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -12,17 +12,17 @@ import * as identicon from '@dicebear/identicon'
|
||||||
* When an empty or no photoUrl is given, a random avatar is generated from the displayName.
|
* When an empty or no photoUrl is given, a random avatar is generated from the displayName.
|
||||||
*
|
*
|
||||||
* @param photoUrl The photo url of the user to use. Maybe empty or not set.
|
* @param photoUrl The photo url of the user to use. Maybe empty or not set.
|
||||||
* @param displayName The display name of the user to use as input to the random avatar.
|
* @param username The username of the user to use as input to the random avatar.
|
||||||
* @return The correct avatar url for the user.
|
* @return The correct avatar url for the user.
|
||||||
*/
|
*/
|
||||||
export const useAvatarUrl = (photoUrl: string | undefined, displayName: string): string => {
|
export const useAvatarUrl = (photoUrl: string | undefined, username: string): string => {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (photoUrl && photoUrl.trim() !== '') {
|
if (photoUrl && photoUrl.trim() !== '') {
|
||||||
return photoUrl
|
return photoUrl
|
||||||
}
|
}
|
||||||
const avatar = createAvatar(identicon, {
|
const avatar = createAvatar(identicon, {
|
||||||
seed: displayName
|
seed: username
|
||||||
})
|
})
|
||||||
return avatar.toDataUri()
|
return avatar.toDataUri()
|
||||||
}, [photoUrl, displayName])
|
}, [photoUrl, username])
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,5 +19,5 @@ export interface UserAvatarForUserProps extends Omit<UserAvatarProps, 'photoUrl'
|
||||||
* @param props remaining avatar props
|
* @param props remaining avatar props
|
||||||
*/
|
*/
|
||||||
export const UserAvatarForUser: React.FC<UserAvatarForUserProps> = ({ user, ...props }) => {
|
export const UserAvatarForUser: React.FC<UserAvatarForUserProps> = ({ user, ...props }) => {
|
||||||
return <UserAvatar displayName={user.displayName} photoUrl={user.photoUrl} {...props} />
|
return <UserAvatar displayName={user.displayName} photoUrl={user.photoUrl} username={user.username} {...props} />
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { getUser } from '../../../api/users'
|
import { getUserInfo } from '../../../api/users'
|
||||||
import { AsyncLoadingBoundary } from '../async-loading-boundary/async-loading-boundary'
|
import { AsyncLoadingBoundary } from '../async-loading-boundary/async-loading-boundary'
|
||||||
import type { UserAvatarProps } from './user-avatar'
|
import type { UserAvatarProps } from './user-avatar'
|
||||||
import { UserAvatar } from './user-avatar'
|
import { UserAvatar } from './user-avatar'
|
||||||
import React, { Fragment, useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAsync } from 'react-use'
|
import { useAsync } from 'react-use'
|
||||||
|
|
||||||
|
@ -28,15 +28,18 @@ export const UserAvatarForUsername: React.FC<UserAvatarForUsernameProps> = ({ us
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { error, value, loading } = useAsync(async (): Promise<{ displayName: string; photo?: string }> => {
|
const { error, value, loading } = useAsync(async (): Promise<{ displayName: string; photo?: string }> => {
|
||||||
return username
|
return username
|
||||||
? await getUser(username)
|
? await getUserInfo(username)
|
||||||
: {
|
: {
|
||||||
displayName: t('common.guestUser')
|
displayName: t('common.guestUser')
|
||||||
}
|
}
|
||||||
}, [username, t])
|
}, [username, t])
|
||||||
|
|
||||||
const avatar = useMemo(() => {
|
const avatar = useMemo(() => {
|
||||||
return !value ? <Fragment /> : <UserAvatar displayName={value.displayName} photoUrl={value.photo} {...props} />
|
if (!value) {
|
||||||
}, [props, value])
|
return null
|
||||||
|
}
|
||||||
|
return <UserAvatar displayName={value.displayName} photoUrl={value.photo} username={username} {...props} />
|
||||||
|
}, [props, value, username])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AsyncLoadingBoundary loading={loading || !value} error={error} componentName={'UserAvatarForUsername'}>
|
<AsyncLoadingBoundary loading={loading || !value} error={error} componentName={'UserAvatarForUsername'}>
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -14,6 +14,7 @@ export interface UserAvatarProps {
|
||||||
showName?: boolean
|
showName?: boolean
|
||||||
photoUrl?: string
|
photoUrl?: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
username?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -29,7 +30,8 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||||
displayName,
|
displayName,
|
||||||
size,
|
size,
|
||||||
additionalClasses = '',
|
additionalClasses = '',
|
||||||
showName = true
|
showName = true,
|
||||||
|
username
|
||||||
}) => {
|
}) => {
|
||||||
const imageSize = useMemo(() => {
|
const imageSize = useMemo(() => {
|
||||||
switch (size) {
|
switch (size) {
|
||||||
|
@ -42,7 +44,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||||
}
|
}
|
||||||
}, [size])
|
}, [size])
|
||||||
|
|
||||||
const avatarUrl = useAvatarUrl(photoUrl, displayName)
|
const avatarUrl = useAvatarUrl(photoUrl, username ?? displayName)
|
||||||
|
|
||||||
const imageTranslateOptions = useMemo(
|
const imageTranslateOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { removeUserPermission, setUserPermission } from '../../../../../../api/permissions'
|
import { removeUserPermission, setUserPermission } from '../../../../../../api/permissions'
|
||||||
import { getUser } from '../../../../../../api/users'
|
import { getUserInfo } from '../../../../../../api/users'
|
||||||
import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
|
||||||
import { setNotePermissionsFromServer } from '../../../../../../redux/note-details/methods'
|
import { setNotePermissionsFromServer } from '../../../../../../redux/note-details/methods'
|
||||||
import { UserAvatarForUser } from '../../../../../common/user-avatar/user-avatar-for-user'
|
import { UserAvatarForUser } from '../../../../../common/user-avatar/user-avatar-for-user'
|
||||||
|
@ -79,7 +79,7 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps & Permission
|
||||||
}, [noteId, entry.username, showErrorNotification])
|
}, [noteId, entry.username, showErrorNotification])
|
||||||
|
|
||||||
const { value, loading, error } = useAsync(async () => {
|
const { value, loading, error } = useAsync(async () => {
|
||||||
return await getUser(entry.username)
|
return await getUserInfo(entry.username)
|
||||||
}, [entry.username])
|
}, [entry.username])
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { RevisionDetails } from '../../../../../../api/revisions/types'
|
import type { RevisionDetails } from '../../../../../../api/revisions/types'
|
||||||
import { getUser } from '../../../../../../api/users'
|
import { getUserInfo } from '../../../../../../api/users'
|
||||||
import type { UserInfo } from '../../../../../../api/users/types'
|
import type { UserInfo } from '../../../../../../api/users/types'
|
||||||
import { download } from '../../../../../common/download/download'
|
import { download } from '../../../../../common/download/download'
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ export const getUserDataForRevision = async (usernames: string[]): Promise<UserI
|
||||||
const users: UserInfo[] = []
|
const users: UserInfo[] = []
|
||||||
const usersToFetch = Math.min(usernames.length, DISPLAY_MAX_USERS_PER_REVISION) - 1
|
const usersToFetch = Math.min(usernames.length, DISPLAY_MAX_USERS_PER_REVISION) - 1
|
||||||
for (let i = 0; i <= usersToFetch; i++) {
|
for (let i = 0; i <= usersToFetch; i++) {
|
||||||
const user = await getUser(usernames[i])
|
const user = await getUserInfo(usernames[i])
|
||||||
users.push(user)
|
users.push(user)
|
||||||
}
|
}
|
||||||
return users
|
return users
|
||||||
|
|
|
@ -25,7 +25,7 @@ export const SignOutDropdownButton: React.FC = () => {
|
||||||
const onSignOut = useCallback(() => {
|
const onSignOut = useCallback(() => {
|
||||||
clearUser()
|
clearUser()
|
||||||
doLogout()
|
doLogout()
|
||||||
.then(() => router.push('/login'))
|
.then((logoutResponse) => router.push(logoutResponse.redirect))
|
||||||
.catch(showErrorNotification('login.logoutFailed'))
|
.catch(showErrorNotification('login.logoutFailed'))
|
||||||
}, [showErrorNotification, router])
|
}, [showErrorNotification, router])
|
||||||
|
|
||||||
|
|
44
frontend/src/components/layout/login-layout.tsx
Normal file
44
frontend/src/components/layout/login-layout.tsx
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import type { PropsWithChildren } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import { Col, Container, Row } from 'react-bootstrap'
|
||||||
|
import { EditorToRendererCommunicatorContextProvider } from '../editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
||||||
|
import { HedgeDocLogoVertical } from '../common/hedge-doc-logo/hedge-doc-logo-vertical'
|
||||||
|
import { LogoSize } from '../common/hedge-doc-logo/logo-size'
|
||||||
|
import { Trans } from 'react-i18next'
|
||||||
|
import { CustomBranding } from '../common/custom-branding/custom-branding'
|
||||||
|
import { IntroCustomContent } from '../intro-page/intro-custom-content'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout for the login page with the intro content on the left and children on the right.
|
||||||
|
* @param children The content to show on the right
|
||||||
|
*/
|
||||||
|
export const LoginLayout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Row>
|
||||||
|
<Col xs={8}>
|
||||||
|
<EditorToRendererCommunicatorContextProvider>
|
||||||
|
<div className={'d-flex flex-column align-items-center mt-3'}>
|
||||||
|
<HedgeDocLogoVertical size={LogoSize.BIG} autoTextColor={true} />
|
||||||
|
<h5>
|
||||||
|
<Trans i18nKey='app.slogan' />
|
||||||
|
</h5>
|
||||||
|
<div className={'mb-5'}>
|
||||||
|
<CustomBranding />
|
||||||
|
</div>
|
||||||
|
<IntroCustomContent />
|
||||||
|
</div>
|
||||||
|
</EditorToRendererCommunicatorContextProvider>
|
||||||
|
</Col>
|
||||||
|
<Col xs={4} className={'pt-3 d-flex gap-3 flex-column'}>
|
||||||
|
{children}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -13,6 +13,7 @@ import { Alert, Button, Card, Form } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { fetchAndSetUser } from '../utils/fetch-and-set-user'
|
import { fetchAndSetUser } from '../utils/fetch-and-set-user'
|
||||||
import { PasswordField } from '../password-field'
|
import { PasswordField } from '../password-field'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
export interface ViaLdapProps {
|
export interface ViaLdapProps {
|
||||||
providerName: string
|
providerName: string
|
||||||
|
@ -25,18 +26,32 @@ export interface ViaLdapProps {
|
||||||
export const LdapLoginCard: React.FC<ViaLdapProps> = ({ providerName, identifier }) => {
|
export const LdapLoginCard: React.FC<ViaLdapProps> = ({ providerName, identifier }) => {
|
||||||
useTranslation()
|
useTranslation()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState<string>()
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
const onLoginSubmit = useCallback(
|
const onLoginSubmit = useCallback(
|
||||||
(event: FormEvent) => {
|
(event: FormEvent) => {
|
||||||
|
let redirect = false
|
||||||
doLdapLogin(identifier, username, password)
|
doLdapLogin(identifier, username, password)
|
||||||
.then(() => fetchAndSetUser())
|
.then((response) => {
|
||||||
.catch((error: Error) => setError(error.message))
|
if (response.newUser) {
|
||||||
|
router.push('/new-user')
|
||||||
|
} else {
|
||||||
|
redirect = true
|
||||||
|
return fetchAndSetUser()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (redirect) {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: Error) => setError(String(error)))
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
},
|
},
|
||||||
[username, password, identifier]
|
[username, password, identifier, router]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onUsernameChange = useLowercaseOnInputChange(setUsername)
|
const onUsernameChange = useLowercaseOnInputChange(setUsername)
|
||||||
|
@ -49,10 +64,10 @@ export const LdapLoginCard: React.FC<ViaLdapProps> = ({ providerName, identifier
|
||||||
<Trans i18nKey='login.signInVia' values={{ service: providerName }} />
|
<Trans i18nKey='login.signInVia' values={{ service: providerName }} />
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
<Form onSubmit={onLoginSubmit} className={'d-flex gap-3 flex-column'}>
|
<Form onSubmit={onLoginSubmit} className={'d-flex gap-3 flex-column'}>
|
||||||
<UsernameField onChange={onUsernameChange} isValid={!!error} value={username} />
|
<UsernameField onChange={onUsernameChange} isInvalid={!!error} value={username} />
|
||||||
<PasswordField onChange={onPasswordChange} invalid={!!error} />
|
<PasswordField onChange={onPasswordChange} isInvalid={!!error} />
|
||||||
<Alert className='small' show={!!error} variant='danger'>
|
<Alert className='small' show={!!error} variant='danger'>
|
||||||
<Trans i18nKey={error} />
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Button type='submit' variant='primary'>
|
<Button type='submit' variant='primary'>
|
||||||
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -53,7 +53,7 @@ export const LocalLoginCardBody: React.FC = () => {
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
<Form onSubmit={onLoginSubmit} className={'d-flex gap-3 flex-column'}>
|
<Form onSubmit={onLoginSubmit} className={'d-flex gap-3 flex-column'}>
|
||||||
<UsernameField onChange={onUsernameChange} isInvalid={!!error} value={username} />
|
<UsernameField onChange={onUsernameChange} isInvalid={!!error} value={username} />
|
||||||
<PasswordField onChange={onPasswordChange} invalid={!!error} />
|
<PasswordField onChange={onPasswordChange} isInvalid={!!error} />
|
||||||
<Alert className='small' show={!!error} variant='danger'>
|
<Alert className='small' show={!!error} variant='danger'>
|
||||||
<Trans i18nKey={error} />
|
<Trans i18nKey={error} />
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
@ -1,9 +1,3 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
@ -25,9 +19,10 @@ import { UsernameLabelField } from '../../../common/fields/username-label-field'
|
||||||
import { DisplayNameField } from '../../../common/fields/display-name-field'
|
import { DisplayNameField } from '../../../common/fields/display-name-field'
|
||||||
import { NewPasswordField } from '../../../common/fields/new-password-field'
|
import { NewPasswordField } from '../../../common/fields/new-password-field'
|
||||||
import { PasswordAgainField } from '../../../common/fields/password-again-field'
|
import { PasswordAgainField } from '../../../common/fields/password-again-field'
|
||||||
import { RegisterInfos } from '../../../register-page/register-infos'
|
import { RegisterInfos } from './register-infos'
|
||||||
import { RegisterError } from '../../../register-page/register-error'
|
import { RegisterError } from './register-error'
|
||||||
import { fetchAndSetUser } from '../../utils/fetch-and-set-user'
|
import { fetchAndSetUser } from '../../utils/fetch-and-set-user'
|
||||||
|
import { useGetPostLoginRedirectUrl } from '../../utils/use-get-post-login-redirect-url'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the registration process with fields for username, display name, password, password retype and information about terms and conditions.
|
* Renders the registration process with fields for username, display name, password, password retype and information about terms and conditions.
|
||||||
|
@ -43,29 +38,34 @@ export const LocalRegisterForm: NextPage = () => {
|
||||||
const [error, setError] = useState<ApiError>()
|
const [error, setError] = useState<ApiError>()
|
||||||
|
|
||||||
const { dispatchUiNotification } = useUiNotifications()
|
const { dispatchUiNotification } = useUiNotifications()
|
||||||
|
const postLoginRedirectUrl = useGetPostLoginRedirectUrl()
|
||||||
|
|
||||||
const doRegisterSubmit = useCallback(
|
const doRegisterSubmit = useCallback(
|
||||||
(event: FormEvent) => {
|
(event: FormEvent) => {
|
||||||
doLocalRegister(username, displayName, password)
|
doLocalRegister(username, displayName, password)
|
||||||
.then(() => fetchAndSetUser())
|
.then(() => fetchAndSetUser())
|
||||||
.then(() => dispatchUiNotification('login.register.success.title', 'login.register.success.message', {}))
|
.then(() => dispatchUiNotification('login.register.success.title', 'login.register.success.message', {}))
|
||||||
.then(() => router.push('/history'))
|
.then(() => router.push(postLoginRedirectUrl))
|
||||||
.catch((error: ApiError) => setError(error))
|
.catch((error: ApiError) => setError(error))
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
},
|
},
|
||||||
[username, displayName, password, dispatchUiNotification, router]
|
[username, displayName, password, dispatchUiNotification, router, postLoginRedirectUrl]
|
||||||
)
|
)
|
||||||
|
|
||||||
const ready = useMemo(() => {
|
const ready = useMemo(() => {
|
||||||
return username.trim() !== '' && displayName.trim() !== '' && password.trim() !== '' && password === passwordAgain
|
return (
|
||||||
|
username.length >= 3 &&
|
||||||
|
username.length <= 64 &&
|
||||||
|
displayName.trim() !== '' &&
|
||||||
|
password.length >= 6 &&
|
||||||
|
password === passwordAgain
|
||||||
|
)
|
||||||
}, [username, password, displayName, passwordAgain])
|
}, [username, password, displayName, passwordAgain])
|
||||||
|
|
||||||
const isWeakPassword = useMemo(() => {
|
const isWeakPassword = useMemo(() => {
|
||||||
return error?.backendErrorName === 'PasswordTooWeakError'
|
return error?.backendErrorName === 'PasswordTooWeakError'
|
||||||
}, [error])
|
}, [error])
|
||||||
|
|
||||||
const isValidUsername = useMemo(() => Boolean(username.trim()), [username])
|
|
||||||
|
|
||||||
const onUsernameChange = useLowercaseOnInputChange(setUsername)
|
const onUsernameChange = useLowercaseOnInputChange(setUsername)
|
||||||
const onDisplayNameChange = useOnInputChange(setDisplayName)
|
const onDisplayNameChange = useOnInputChange(setDisplayName)
|
||||||
const onPasswordChange = useOnInputChange(setPassword)
|
const onPasswordChange = useOnInputChange(setPassword)
|
||||||
|
@ -73,7 +73,7 @@ export const LocalRegisterForm: NextPage = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={doRegisterSubmit} className={'d-flex flex-column gap-3'}>
|
<Form onSubmit={doRegisterSubmit} className={'d-flex flex-column gap-3'}>
|
||||||
<UsernameLabelField onChange={onUsernameChange} value={username} isValid={isValidUsername} />
|
<UsernameLabelField onChange={onUsernameChange} value={username} />
|
||||||
<DisplayNameField onChange={onDisplayNameChange} value={displayName} />
|
<DisplayNameField onChange={onDisplayNameChange} value={displayName} />
|
||||||
<NewPasswordField onChange={onPasswordChange} value={password} hasError={isWeakPassword} />
|
<NewPasswordField onChange={onPasswordChange} value={password} hasError={isWeakPassword} />
|
||||||
<PasswordAgainField
|
<PasswordAgainField
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { ErrorToI18nKeyMapper } from '../../api/common/error-to-i18n-key-mapper'
|
import { ErrorToI18nKeyMapper } from '../../../../api/common/error-to-i18n-key-mapper'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { Alert } from 'react-bootstrap'
|
import { Alert } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
|
@ -1,10 +1,10 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { useFrontendConfig } from '../common/frontend-config-context/use-frontend-config'
|
import { useFrontendConfig } from '../../../common/frontend-config-context/use-frontend-config'
|
||||||
import { TranslatedExternalLink } from '../common/links/translated-external-link'
|
import { TranslatedExternalLink } from '../../../common/links/translated-external-link'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
|
124
frontend/src/components/login-page/new-user/new-user-card.tsx
Normal file
124
frontend/src/components/login-page/new-user/new-user-card.tsx
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Button, Card, Form } from 'react-bootstrap'
|
||||||
|
import { Trans } from 'react-i18next'
|
||||||
|
import { useAsync } from 'react-use'
|
||||||
|
import { cancelPendingUser, confirmPendingUser, getPendingUserInfo } from '../../../api/auth/pending-user'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
|
||||||
|
import { UsernameLabelField } from '../../common/fields/username-label-field'
|
||||||
|
import { DisplayNameField } from '../../common/fields/display-name-field'
|
||||||
|
import { ProfilePictureChoice, ProfilePictureSelectField } from '../../common/fields/profile-picture-select-field'
|
||||||
|
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
|
||||||
|
import { fetchAndSetUser } from '../utils/fetch-and-set-user'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card where a new user can enter their user information.
|
||||||
|
*/
|
||||||
|
export const NewUserCard: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { showErrorNotification } = useUiNotifications()
|
||||||
|
const { value, error, loading } = useAsync(getPendingUserInfo, [])
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [displayName, setDisplayName] = useState('')
|
||||||
|
const [pictureChoice, setPictureChoice] = useState(ProfilePictureChoice.FALLBACK)
|
||||||
|
const [isUsernameSubmittable, setIsUsernameSubmittable] = useState(false)
|
||||||
|
const [isDisplayNameSubmittable, setIsDisplayNameSubmittable] = useState(false)
|
||||||
|
|
||||||
|
const isSubmittable = useMemo(() => {
|
||||||
|
return isUsernameSubmittable && isDisplayNameSubmittable
|
||||||
|
}, [isUsernameSubmittable, isDisplayNameSubmittable])
|
||||||
|
|
||||||
|
const onChangeUsername = useOnInputChange(setUsername)
|
||||||
|
const onChangeDisplayName = useOnInputChange(setDisplayName)
|
||||||
|
|
||||||
|
const submitUserdata = useCallback(() => {
|
||||||
|
confirmPendingUser({
|
||||||
|
username,
|
||||||
|
displayName,
|
||||||
|
profilePicture: pictureChoice === ProfilePictureChoice.PROVIDER ? value?.photoUrl : undefined
|
||||||
|
})
|
||||||
|
.then(() => fetchAndSetUser())
|
||||||
|
.then(() => {
|
||||||
|
router.push('/')
|
||||||
|
})
|
||||||
|
.catch(showErrorNotification('login.welcome.error'))
|
||||||
|
}, [username, displayName, pictureChoice, router, showErrorNotification, value?.photoUrl])
|
||||||
|
|
||||||
|
const cancelUserCreation = useCallback(() => {
|
||||||
|
cancelPendingUser()
|
||||||
|
.catch(showErrorNotification('login.welcome.cancelError'))
|
||||||
|
.finally(() => {
|
||||||
|
router.push('/login')
|
||||||
|
})
|
||||||
|
}, [router, showErrorNotification])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
showErrorNotification('login.welcome.error')(error)
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, [error, router, showErrorNotification])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUsername(value.username ?? '')
|
||||||
|
setDisplayName(value.displayName ?? '')
|
||||||
|
if (value.photoUrl) {
|
||||||
|
setPictureChoice(ProfilePictureChoice.PROVIDER)
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
if (!value && !loading) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
{loading && <p>Loading...</p>}
|
||||||
|
<Card.Title>
|
||||||
|
{displayName !== '' ? (
|
||||||
|
<Trans i18nKey={'login.welcome.title'} values={{ name: displayName }} />
|
||||||
|
) : (
|
||||||
|
<Trans i18nKey={'login.welcome.titleFallback'} />
|
||||||
|
)}
|
||||||
|
</Card.Title>
|
||||||
|
<Trans i18nKey={'login.welcome.description'} />
|
||||||
|
<hr />
|
||||||
|
<Form onSubmit={submitUserdata} className={'d-flex flex-column gap-3'}>
|
||||||
|
<DisplayNameField
|
||||||
|
onChange={onChangeDisplayName}
|
||||||
|
value={displayName}
|
||||||
|
onValidityChange={setIsDisplayNameSubmittable}
|
||||||
|
/>
|
||||||
|
<UsernameLabelField
|
||||||
|
onChange={onChangeUsername}
|
||||||
|
value={username}
|
||||||
|
onValidityChange={setIsUsernameSubmittable}
|
||||||
|
/>
|
||||||
|
<ProfilePictureSelectField
|
||||||
|
onChange={setPictureChoice}
|
||||||
|
value={pictureChoice}
|
||||||
|
pictureUrl={value?.photoUrl}
|
||||||
|
username={username}
|
||||||
|
/>
|
||||||
|
<div className={'d-flex gap-3'}>
|
||||||
|
<Button variant={'secondary'} type={'button'} className={'w-50'} onClick={cancelUserCreation}>
|
||||||
|
<Trans i18nKey={'common.cancel'} />
|
||||||
|
</Button>
|
||||||
|
<Button variant={'success'} type={'submit'} className={'w-50'} disabled={!isSubmittable}>
|
||||||
|
<Trans i18nKey={'common.continue'} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,21 +1,25 @@
|
||||||
/*
|
/*
|
||||||
* 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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import styles from './via-one-click.module.scss'
|
|
||||||
import type { Icon } from 'react-bootstrap-icons'
|
import type { Icon } from 'react-bootstrap-icons'
|
||||||
import {
|
import {
|
||||||
Exclamation as IconExclamation,
|
Exclamation as IconExclamation,
|
||||||
Github as IconGithub,
|
Github as IconGithub,
|
||||||
Google as IconGoogle,
|
Google as IconGoogle,
|
||||||
People as IconPeople,
|
PersonRolodex as IconPersonRolodex,
|
||||||
PersonRolodex as IconPersonRolodex
|
Microsoft as IconMicrosoft,
|
||||||
|
Paypal as IconPaypal,
|
||||||
|
Discord as IconDiscord,
|
||||||
|
Facebook as IconFacebook,
|
||||||
|
Mastodon as IconMastodon
|
||||||
} from 'react-bootstrap-icons'
|
} from 'react-bootstrap-icons'
|
||||||
import { Logger } from '../../../utils/logger'
|
import { Logger } from '../../../utils/logger'
|
||||||
import type { AuthProvider } from '../../../api/config/types'
|
import type { AuthProvider } from '../../../api/config/types'
|
||||||
import { AuthProviderType } from '../../../api/config/types'
|
import { AuthProviderType } from '../../../api/config/types'
|
||||||
import { IconGitlab } from '../../common/icons/additional/icon-gitlab'
|
import { IconGitlab } from '../../common/icons/additional/icon-gitlab'
|
||||||
|
import styles from './one-click-login-button.module.scss'
|
||||||
|
|
||||||
export interface OneClickMetadata {
|
export interface OneClickMetadata {
|
||||||
name: string
|
name: string
|
||||||
|
@ -24,10 +28,6 @@ export interface OneClickMetadata {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBackendAuthUrl = (providerIdentifer: string): string => {
|
|
||||||
return `/auth/${providerIdentifer}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const logger = new Logger('GetOneClickProviderMetadata')
|
const logger = new Logger('GetOneClickProviderMetadata')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -37,43 +37,7 @@ const logger = new Logger('GetOneClickProviderMetadata')
|
||||||
* @return Name, icon, URL and CSS class of the given provider for rendering a login button.
|
* @return Name, icon, URL and CSS class of the given provider for rendering a login button.
|
||||||
*/
|
*/
|
||||||
export const getOneClickProviderMetadata = (provider: AuthProvider): OneClickMetadata => {
|
export const getOneClickProviderMetadata = (provider: AuthProvider): OneClickMetadata => {
|
||||||
switch (provider.type) {
|
if (provider.type !== AuthProviderType.OIDC) {
|
||||||
case AuthProviderType.GITHUB:
|
|
||||||
return {
|
|
||||||
name: 'GitHub',
|
|
||||||
icon: IconGithub,
|
|
||||||
className: styles['btn-social-github'],
|
|
||||||
url: getBackendAuthUrl('github')
|
|
||||||
}
|
|
||||||
case AuthProviderType.GITLAB:
|
|
||||||
return {
|
|
||||||
name: provider.providerName,
|
|
||||||
icon: IconGitlab,
|
|
||||||
className: styles['btn-social-gitlab'],
|
|
||||||
url: getBackendAuthUrl(provider.identifier)
|
|
||||||
}
|
|
||||||
case AuthProviderType.GOOGLE:
|
|
||||||
return {
|
|
||||||
name: 'Google',
|
|
||||||
icon: IconGoogle,
|
|
||||||
className: styles['btn-social-google'],
|
|
||||||
url: getBackendAuthUrl('google')
|
|
||||||
}
|
|
||||||
case AuthProviderType.OAUTH2:
|
|
||||||
return {
|
|
||||||
name: provider.providerName,
|
|
||||||
icon: IconPersonRolodex,
|
|
||||||
className: 'btn-primary',
|
|
||||||
url: getBackendAuthUrl(provider.identifier)
|
|
||||||
}
|
|
||||||
case AuthProviderType.SAML:
|
|
||||||
return {
|
|
||||||
name: provider.providerName,
|
|
||||||
icon: IconPeople,
|
|
||||||
className: 'btn-success',
|
|
||||||
url: getBackendAuthUrl(provider.identifier)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
logger.warn('Metadata for one-click-provider does not exist', provider)
|
logger.warn('Metadata for one-click-provider does not exist', provider)
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
|
@ -82,4 +46,48 @@ export const getOneClickProviderMetadata = (provider: AuthProvider): OneClickMet
|
||||||
url: '#'
|
url: '#'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let icon: Icon = IconPersonRolodex
|
||||||
|
let className: string = 'btn-primary'
|
||||||
|
switch (provider.theme) {
|
||||||
|
case 'github':
|
||||||
|
className = styles.github
|
||||||
|
icon = IconGithub
|
||||||
|
break
|
||||||
|
case 'google':
|
||||||
|
className = styles.google
|
||||||
|
icon = IconGoogle
|
||||||
|
break
|
||||||
|
case 'gitlab':
|
||||||
|
className = styles.gitlab
|
||||||
|
icon = IconGitlab
|
||||||
|
break
|
||||||
|
case 'facebook':
|
||||||
|
className = styles.facebook
|
||||||
|
icon = IconFacebook
|
||||||
|
break
|
||||||
|
case 'mastodon':
|
||||||
|
className = styles.mastodon
|
||||||
|
icon = IconMastodon
|
||||||
|
break
|
||||||
|
case 'discord':
|
||||||
|
className = styles.discord
|
||||||
|
icon = IconDiscord
|
||||||
|
break
|
||||||
|
case 'paypal':
|
||||||
|
className = styles.paypal
|
||||||
|
icon = IconPaypal
|
||||||
|
break
|
||||||
|
case 'azure':
|
||||||
|
case 'microsoft':
|
||||||
|
case 'outlook':
|
||||||
|
className = styles.azure
|
||||||
|
icon = IconMicrosoft
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: provider.providerName,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
url: `/api/private/auth/oidc/${provider.identifier}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*!
|
||||||
|
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
@function brightness($color) {
|
||||||
|
@return ((red($color) * 299) + (green($color) * 587) + (blue($color) * 114)) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin button($color) {
|
||||||
|
$font-color: if(brightness($color) > 128, #000000, #ffffff);
|
||||||
|
color: $font-color;
|
||||||
|
background-color: $color;
|
||||||
|
&:hover {
|
||||||
|
background-color: darken($color, 15%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.github {
|
||||||
|
@include button(#444444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gitlab {
|
||||||
|
@include button(#FA7035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.google {
|
||||||
|
@include button(#DD4B39);
|
||||||
|
}
|
||||||
|
|
||||||
|
.azure {
|
||||||
|
@include button(#008AD7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facebook {
|
||||||
|
@include button(#0165E1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mastodon {
|
||||||
|
@include button(#563ACC);
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord {
|
||||||
|
@include button(#5865F2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paypal {
|
||||||
|
@include button(#00457C);
|
||||||
|
}
|
|
@ -1,25 +0,0 @@
|
||||||
/*!
|
|
||||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
@mixin button($color) {
|
|
||||||
color: #ffffff;
|
|
||||||
background-color: $color;
|
|
||||||
&:hover {
|
|
||||||
background-color: darken($color, 15%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-social-github {
|
|
||||||
@include button(#444444);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-social-gitlab {
|
|
||||||
@include button(#FA7035);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-social-google {
|
|
||||||
@include button(#DD4B39);
|
|
||||||
}
|
|
|
@ -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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -10,7 +10,7 @@ import { useTranslatedText } from '../../hooks/common/use-translated-text'
|
||||||
|
|
||||||
export interface AuthFieldProps {
|
export interface AuthFieldProps {
|
||||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void
|
onChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||||
invalid: boolean
|
isInvalid: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -19,13 +19,13 @@ export interface AuthFieldProps {
|
||||||
* @param onChange Hook that is called when the entered password changes.
|
* @param onChange Hook that is called when the entered password changes.
|
||||||
* @param invalid True when the entered password is invalid, false otherwise.
|
* @param invalid True when the entered password is invalid, false otherwise.
|
||||||
*/
|
*/
|
||||||
export const PasswordField: React.FC<AuthFieldProps> = ({ onChange, invalid }) => {
|
export const PasswordField: React.FC<AuthFieldProps> = ({ onChange, isInvalid }) => {
|
||||||
const placeholderText = useTranslatedText('login.auth.password')
|
const placeholderText = useTranslatedText('login.auth.password')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
isInvalid={invalid}
|
isInvalid={isInvalid}
|
||||||
type='password'
|
type='password'
|
||||||
size='sm'
|
size='sm'
|
||||||
placeholder={placeholderText}
|
placeholder={placeholderText}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue