feat(auth): refactor auth, add oidc
Some checks are pending
Docker / build-and-push (frontend) (push) Waiting to run
Docker / build-and-push (backend) (push) Waiting to run
Deploy HD2 docs to Netlify / Deploys to netlify (push) Waiting to run
E2E Tests / backend-sqlite (push) Waiting to run
E2E Tests / backend-mariadb (push) Waiting to run
E2E Tests / backend-postgres (push) Waiting to run
E2E Tests / Build test build of frontend (push) Waiting to run
E2E Tests / frontend-cypress (1) (push) Blocked by required conditions
E2E Tests / frontend-cypress (2) (push) Blocked by required conditions
E2E Tests / frontend-cypress (3) (push) Blocked by required conditions
Lint and check format / Lint files and check formatting (push) Waiting to run
REUSE Compliance Check / reuse (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Static Analysis / Njsscan code scanning (push) Waiting to run
Static Analysis / CodeQL analysis (push) Waiting to run
Run tests & build / Test and build with NodeJS 20 (push) Waiting to run

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:
Erik Michelson 2024-03-23 02:10:25 +01:00
parent 1609f3e01f
commit 7f665fae4b
109 changed files with 2927 additions and 1700 deletions

View file

@ -64,6 +64,7 @@
"minio": "7.1.3",
"mysql": "2.18.1",
"node-fetch": "2.7.0",
"openid-client": "5.6.5",
"passport": "0.7.0",
"passport-custom": "1.1.1",
"passport-http-bearer": "1.0.1",

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -8,123 +8,106 @@ import {
Body,
Controller,
Delete,
Param,
Post,
Get,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Session } from 'express-session';
import { IdentityService } from '../../../identity/identity.service';
import { LdapLoginDto } from '../../../identity/ldap/ldap-login.dto';
import { LdapAuthGuard } from '../../../identity/ldap/ldap.strategy';
import { LocalAuthGuard } from '../../../identity/local/local.strategy';
import { LoginDto } from '../../../identity/local/login.dto';
import { RegisterDto } from '../../../identity/local/register.dto';
import { UpdatePasswordDto } from '../../../identity/local/update-password.dto';
import { SessionGuard } from '../../../identity/session.guard';
import { OidcService } from '../../../identity/oidc/oidc.service';
import { PendingUserConfirmationDto } from '../../../identity/pending-user-confirmation.dto';
import { ProviderType } from '../../../identity/provider-type.enum';
import {
RequestWithSession,
SessionGuard,
} from '../../../identity/session.guard';
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { SessionState } from '../../../sessions/session.service';
import { User } from '../../../users/user.entity';
import { UsersService } from '../../../users/users.service';
import { makeUsernameLowercase } from '../../../utils/username';
import { LoginEnabledGuard } from '../../utils/login-enabled.guard';
import { FullUserInfoDto } from '../../../users/user-info.dto';
import { OpenApi } from '../../utils/openapi.decorator';
import { RegistrationEnabledGuard } from '../../utils/registration-enabled.guard';
import { RequestUser } from '../../utils/request-user.decorator';
type RequestWithSession = Request & {
session: SessionState;
};
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly logger: ConsoleLoggerService,
private usersService: UsersService,
private identityService: IdentityService,
private oidcService: OidcService,
) {
this.logger.setContext(AuthController.name);
}
@UseGuards(RegistrationEnabledGuard)
@Post('local')
@OpenApi(201, 400, 403, 409)
async registerUser(
@Req() request: RequestWithSession,
@Body() registerDto: RegisterDto,
): Promise<void> {
await this.identityService.checkPasswordStrength(registerDto.password);
const user = await this.usersService.createUser(
registerDto.username,
registerDto.displayName,
);
await this.identityService.createLocalIdentity(user, registerDto.password);
request.session.username = registerDto.username;
request.session.authProvider = 'local';
}
@UseGuards(LoginEnabledGuard, SessionGuard)
@Put('local')
@OpenApi(200, 400, 401)
async updatePassword(
@RequestUser() user: User,
@Body() changePasswordDto: UpdatePasswordDto,
): Promise<void> {
await this.identityService.checkLocalPassword(
user,
changePasswordDto.currentPassword,
);
await this.identityService.updateLocalPassword(
user,
changePasswordDto.newPassword,
);
return;
}
@UseGuards(LoginEnabledGuard, LocalAuthGuard)
@Post('local/login')
@OpenApi(201, 400, 401)
login(
@Req()
request: RequestWithSession,
@Body() loginDto: LoginDto,
): void {
// There is no further testing needed as we only get to this point if LocalAuthGuard was successful
request.session.username = loginDto.username;
request.session.authProvider = 'local';
}
@UseGuards(LdapAuthGuard)
@Post('ldap/:ldapIdentifier')
@OpenApi(201, 400, 401)
loginWithLdap(
@Req()
request: RequestWithSession,
@Param('ldapIdentifier') ldapIdentifier: string,
@Body() loginDto: LdapLoginDto,
): void {
// There is no further testing needed as we only get to this point if LdapAuthGuard was successful
request.session.username = makeUsernameLowercase(loginDto.username);
request.session.authProvider = 'ldap';
}
@UseGuards(SessionGuard)
@Delete('logout')
@OpenApi(204, 400, 401)
logout(@Req() request: Request & { session: Session }): Promise<void> {
return new Promise((resolve, reject) => {
request.session.destroy((err) => {
if (err) {
this.logger.error('Encountered an error while logging out: ${err}');
reject(new BadRequestException('Unable to log out'));
} else {
resolve();
}
});
@OpenApi(200, 400, 401)
logout(@Req() request: RequestWithSession): { redirect: string } {
let logoutUrl: string | null = null;
if (request.session.authProviderType === ProviderType.OIDC) {
logoutUrl = this.oidcService.getLogoutUrl(request);
}
request.session.destroy((err) => {
if (err) {
this.logger.error(
'Error during logout:' + String(err),
undefined,
'logout',
);
throw new BadRequestException('Unable to log out');
}
});
return {
redirect: logoutUrl || '/',
};
}
@Get('pending-user')
@OpenApi(200, 400)
getPendingUserData(
@Req() request: RequestWithSession,
): Partial<FullUserInfoDto> {
if (
!request.session.newUserData ||
!request.session.authProviderIdentifier ||
!request.session.authProviderType
) {
throw new BadRequestException('No pending user data');
}
return request.session.newUserData;
}
@Put('pending-user')
@OpenApi(204, 400)
async confirmPendingUserData(
@Req() request: RequestWithSession,
@Body() updatedUserInfo: PendingUserConfirmationDto,
): Promise<void> {
if (
!request.session.newUserData ||
!request.session.authProviderIdentifier ||
!request.session.authProviderType ||
!request.session.providerUserId
) {
throw new BadRequestException('No pending user data');
}
const identity = await this.identityService.createUserWithIdentity(
request.session.newUserData,
updatedUserInfo,
request.session.authProviderType,
request.session.authProviderIdentifier,
request.session.providerUserId,
);
request.session.username = (await identity.user).username;
// Cleanup
request.session.newUserData = undefined;
}
@Delete('pending-user')
@OpenApi(204, 400)
deletePendingUserData(@Req() request: RequestWithSession): void {
request.session.newUserData = undefined;
request.session.authProviderIdentifier = undefined;
request.session.authProviderType = undefined;
request.session.providerUserId = undefined;
}
}

View 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');
}
}
}

View 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');
}
}
}

View 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();
}
}
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -75,6 +75,11 @@ export class MeController {
@RequestUser() user: User,
@Body('displayName') newDisplayName: string,
): Promise<void> {
await this.userService.changeDisplayName(user, newDisplayName);
await this.userService.updateUser(
user,
newDisplayName,
undefined,
undefined,
);
}
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -18,6 +18,9 @@ import { RevisionsModule } from '../../revisions/revisions.module';
import { UsersModule } from '../../users/users.module';
import { AliasController } from './alias/alias.controller';
import { AuthController } from './auth/auth.controller';
import { LdapController } from './auth/ldap/ldap.controller';
import { LocalController } from './auth/local/local.controller';
import { OidcController } from './auth/oidc/oidc.controller';
import { ConfigController } from './config/config.controller';
import { GroupsController } from './groups/groups.controller';
import { HistoryController } from './me/history/history.controller';
@ -52,6 +55,9 @@ import { UsersController } from './users/users.controller';
AuthController,
UsersController,
GroupsController,
LdapController,
LocalController,
OidcController,
],
})
export class PrivateApiModule {}

View file

@ -3,11 +3,15 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Controller, Get, Param } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { UserInfoDto } from '../../../users/user-info.dto';
import {
UsernameCheckDto,
UsernameCheckResponseDto,
} from '../../../users/username-check.dto';
import { UsersService } from '../../../users/users.service';
import { Username } from '../../../utils/username';
import { OpenApi } from '../../utils/openapi.decorator';
@ -22,7 +26,20 @@ export class UsersController {
this.logger.setContext(UsersController.name);
}
@Get(':username')
@Post('check')
@HttpCode(200)
@OpenApi(200)
async checkUsername(
@Body() usernameCheck: UsernameCheckDto,
): Promise<UsernameCheckResponseDto> {
const userExists = await this.userService.checkIfUserExists(
usernameCheck.username,
);
// TODO Check if username is blocked
return { usernameAvailable: !userExists };
}
@Get('profile/:username')
@OpenApi(200)
async getUser(@Param('username') username: Username): Promise<UserInfoDto> {
return this.userService.toUserDto(

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -20,12 +20,12 @@ import { CompleteRequest } from './request.type';
export const SessionAuthProvider = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request: CompleteRequest = ctx.switchToHttp().getRequest();
if (!request.session?.authProvider) {
if (!request.session?.authProviderType) {
// We should have an auth provider here, otherwise something is wrong
throw new InternalServerErrorException(
'Session is missing an auth provider identifier',
);
}
return request.session.authProvider;
return request.session.authProviderType;
},
);

View file

@ -1,11 +1,12 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import mockedEnv from 'mocked-env';
import authConfig from './auth.config';
import { Theme } from './theme.enum';
describe('authConfig', () => {
const secret = 'this-is-a-secret';
@ -162,6 +163,7 @@ describe('authConfig', () => {
const searchAttributes = ['mail', 'uid'];
const userIdField = 'non_default_uid';
const displayNameField = 'non_default_display_name';
const emailField = 'non_default_email';
const profilePictureField = 'non_default_profile_picture';
const bindDn = 'cn=admin,dc=planetexpress,dc=com';
const bindCredentials = 'GoodNewsEveryone';
@ -176,6 +178,7 @@ describe('authConfig', () => {
HD_AUTH_LDAP_FUTURAMA_SEARCH_FILTER: searchFilter,
HD_AUTH_LDAP_FUTURAMA_SEARCH_ATTRIBUTES: searchAttributes.join(','),
HD_AUTH_LDAP_FUTURAMA_USER_ID_FIELD: userIdField,
HD_AUTH_LDAP_FUTURAMA_EMAIL_FIELD: emailField,
HD_AUTH_LDAP_FUTURAMA_DISPLAY_NAME_FIELD: displayNameField,
HD_AUTH_LDAP_FUTURAMA_PROFILE_PICTURE_FIELD: profilePictureField,
HD_AUTH_LDAP_FUTURAMA_BIND_DN: bindDn,
@ -199,7 +202,7 @@ describe('authConfig', () => {
const config = authConfig();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
expect(firstLdap.identifier).toEqual(ldapNames[0]);
expect(firstLdap.url).toEqual(url);
expect(firstLdap.providerName).toEqual(providerName);
expect(firstLdap.searchBase).toEqual(searchBase);
@ -207,6 +210,7 @@ describe('authConfig', () => {
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
expect(firstLdap.userIdField).toEqual(userIdField);
expect(firstLdap.displayNameField).toEqual(displayNameField);
expect(firstLdap.emailField).toEqual(emailField);
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
expect(firstLdap.bindDn).toEqual(bindDn);
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
@ -230,7 +234,7 @@ describe('authConfig', () => {
const config = authConfig();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
expect(firstLdap.identifier).toEqual(ldapNames[0]);
expect(firstLdap.url).toEqual(url);
expect(firstLdap.providerName).toEqual('LDAP');
expect(firstLdap.searchBase).toEqual(searchBase);
@ -261,7 +265,7 @@ describe('authConfig', () => {
const config = authConfig();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
expect(firstLdap.identifier).toEqual(ldapNames[0]);
expect(firstLdap.url).toEqual(url);
expect(firstLdap.providerName).toEqual(providerName);
expect(firstLdap.searchBase).toEqual(searchBase);
@ -292,7 +296,7 @@ describe('authConfig', () => {
const config = authConfig();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
expect(firstLdap.identifier).toEqual(ldapNames[0]);
expect(firstLdap.url).toEqual(url);
expect(firstLdap.providerName).toEqual(providerName);
expect(firstLdap.searchBase).toEqual(searchBase);
@ -323,7 +327,7 @@ describe('authConfig', () => {
const config = authConfig();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
expect(firstLdap.identifier).toEqual(ldapNames[0]);
expect(firstLdap.url).toEqual(url);
expect(firstLdap.providerName).toEqual(providerName);
expect(firstLdap.searchBase).toEqual(searchBase);
@ -354,7 +358,7 @@ describe('authConfig', () => {
const config = authConfig();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
expect(firstLdap.identifier).toEqual(ldapNames[0]);
expect(firstLdap.url).toEqual(url);
expect(firstLdap.providerName).toEqual(providerName);
expect(firstLdap.searchBase).toEqual(searchBase);
@ -385,7 +389,7 @@ describe('authConfig', () => {
const config = authConfig();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
expect(firstLdap.identifier).toEqual(ldapNames[0]);
expect(firstLdap.url).toEqual(url);
expect(firstLdap.providerName).toEqual(providerName);
expect(firstLdap.searchBase).toEqual(searchBase);
@ -416,7 +420,7 @@ describe('authConfig', () => {
const config = authConfig();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
expect(firstLdap.identifier).toEqual(ldapNames[0]);
expect(firstLdap.url).toEqual(url);
expect(firstLdap.providerName).toEqual(providerName);
expect(firstLdap.searchBase).toEqual(searchBase);
@ -447,7 +451,7 @@ describe('authConfig', () => {
const config = authConfig();
expect(config.ldap).toHaveLength(1);
const firstLdap = config.ldap[0];
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
expect(firstLdap.identifier).toEqual(ldapNames[0]);
expect(firstLdap.url).toEqual(url);
expect(firstLdap.providerName).toEqual(providerName);
expect(firstLdap.searchBase).toEqual(searchBase);
@ -519,4 +523,441 @@ describe('authConfig', () => {
});
});
});
describe('odic', () => {
const oidcNames = ['gitlab'];
const providerName = 'Gitlab oAuth2';
const issuer = 'https://gitlab.example.org';
const clientId = '1234567890';
const clientSecret = 'ABCDEF';
const theme = Theme.GITHUB;
const authorizeUrl = 'https://example.org/auth';
const tokenUrl = 'https://example.org/token';
const userinfoUrl = 'https://example.org/user';
const scope = 'some scopr';
const defaultScope = 'openid profile email';
const userIdField = 'login';
const defaultUserIdField = 'sub';
const userNameField = 'preferred_username';
const displayNameField = 'displayName';
const defaultDisplayNameField = 'name';
const profilePictureField = 'pictureField';
const defaultProfilePictureField = 'picture';
const emailField = 'a_email';
const defaultEmailField = 'email';
const completeOidcConfig = {
/* eslint-disable @typescript-eslint/naming-convention */
HD_AUTH_OIDC_SERVERS: oidcNames.join(','),
HD_AUTH_OIDC_GITLAB_PROVIDER_NAME: providerName,
HD_AUTH_OIDC_GITLAB_ISSUER: issuer,
HD_AUTH_OIDC_GITLAB_CLIENT_ID: clientId,
HD_AUTH_OIDC_GITLAB_CLIENT_SECRET: clientSecret,
HD_AUTH_OIDC_GITLAB_THEME: theme,
HD_AUTH_OIDC_GITLAB_AUTHORIZE_URL: authorizeUrl,
HD_AUTH_OIDC_GITLAB_TOKEN_URL: tokenUrl,
HD_AUTH_OIDC_GITLAB_USERINFO_URL: userinfoUrl,
HD_AUTH_OIDC_GITLAB_SCOPE: scope,
HD_AUTH_OIDC_GITLAB_USER_ID_FIELD: userIdField,
HD_AUTH_OIDC_GITLAB_USER_NAME_FIELD: userNameField,
HD_AUTH_OIDC_GITLAB_DISPLAY_NAME_FIELD: displayNameField,
HD_AUTH_OIDC_GITLAB_PROFILE_PICTURE_FIELD: profilePictureField,
HD_AUTH_OIDC_GITLAB_EMAIL_FIELD: emailField,
/* eslint-enable @typescript-eslint/naming-convention */
};
describe('is correctly parsed', () => {
it('when given correct and complete environment variables', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = authConfig();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_THEME is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_THEME: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = authConfig();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toBeUndefined();
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_AUTHORIZE_URL is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_AUTHORIZE_URL: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = authConfig();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toBeUndefined();
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_TOKEN_URL is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_TOKEN_URL: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = authConfig();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
expect(firstOidc.tokenUrl).toBeUndefined();
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_USERINFO_URL is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_USERINFO_URL: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = authConfig();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
expect(firstOidc.userinfoUrl).toBeUndefined();
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_SCOPE is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_SCOPE: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = authConfig();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.scope).toEqual(defaultScope);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_USER_ID_FIELD is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_USER_ID_FIELD: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = authConfig();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.userIdField).toEqual(defaultUserIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_DISPLAY_NAME_FIELD is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_DISPLAY_NAME_FIELD: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = authConfig();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(defaultDisplayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(emailField);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_PROFILE_PICTURE_FIELD is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_PROFILE_PICTURE_FIELD: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = authConfig();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(
defaultProfilePictureField,
);
expect(firstOidc.emailField).toEqual(emailField);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_EMAIL_FIELD is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_EMAIL_FIELD: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
const config = authConfig();
expect(config.oidc).toHaveLength(1);
const firstOidc = config.oidc[0];
expect(firstOidc.identifier).toEqual(oidcNames[0]);
expect(firstOidc.issuer).toEqual(issuer);
expect(firstOidc.clientID).toEqual(clientId);
expect(firstOidc.clientSecret).toEqual(clientSecret);
expect(firstOidc.theme).toEqual(theme);
expect(firstOidc.authorizeUrl).toEqual(authorizeUrl);
expect(firstOidc.tokenUrl).toEqual(tokenUrl);
expect(firstOidc.scope).toEqual(scope);
expect(firstOidc.userinfoUrl).toEqual(userinfoUrl);
expect(firstOidc.userIdField).toEqual(userIdField);
expect(firstOidc.userNameField).toEqual(userNameField);
expect(firstOidc.displayNameField).toEqual(displayNameField);
expect(firstOidc.profilePictureField).toEqual(profilePictureField);
expect(firstOidc.emailField).toEqual(defaultEmailField);
restore();
});
});
describe('throws error', () => {
it('when HD_AUTH_OIDC_GITLAB_ISSUER is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_ISSUER: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
expect(() => authConfig()).toThrow(
'"HD_AUTH_OIDC_GITLAB_ISSUER" is required',
);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_CLIENT_ID is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_CLIENT_ID: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
expect(() => authConfig()).toThrow(
'"HD_AUTH_OIDC_GITLAB_CLIENT_ID" is required',
);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_CLIENT_SECRET is not set', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_CLIENT_SECRET: undefined,
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
expect(() => authConfig()).toThrow(
'"HD_AUTH_OIDC_GITLAB_CLIENT_SECRET" is required',
);
restore();
});
it('when HD_AUTH_OIDC_GITLAB_THEME is set to a wrong value', () => {
const restore = mockedEnv(
{
/* eslint-disable @typescript-eslint/naming-convention */
...neededAuthConfig,
...completeOidcConfig,
HD_AUTH_OIDC_GITLAB_THEME: 'something else',
/* eslint-enable @typescript-eslint/naming-convention */
},
{
clear: true,
},
);
expect(() => authConfig()).toThrow('"HD_AUTH_OIDC_GITLAB_THEME"');
restore();
});
});
});
});

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -7,7 +7,7 @@ import { registerAs } from '@nestjs/config';
import * as fs from 'fs';
import * as Joi from 'joi';
import { GitlabScope } from './gitlab.enum';
import { Theme } from './theme.enum';
import {
buildErrorMessage,
ensureNoDuplicatesExist,
@ -16,9 +16,12 @@ import {
toArrayConfig,
} from './utils';
export interface LDAPConfig {
export interface InternalIdentifier {
identifier: string;
providerName: string;
}
export interface LDAPConfig extends InternalIdentifier {
url: string;
bindDn?: string;
bindCredentials?: string;
@ -27,11 +30,33 @@ export interface LDAPConfig {
searchAttributes: string[];
userIdField: string;
displayNameField: string;
emailField: string;
profilePictureField: string;
tlsCaCerts?: string[];
}
export interface OidcConfig extends InternalIdentifier {
issuer: string;
clientID: string;
clientSecret: string;
theme?: string;
authorizeUrl?: string;
tokenUrl?: string;
userinfoUrl?: string;
scope: string;
userNameField: string;
userIdField: string;
displayNameField: string;
profilePictureField: string;
emailField: string;
}
export interface AuthConfig {
common: {
allowProfileEdits: boolean;
allowChooseUsername: boolean;
syncSource?: string;
};
session: {
secret: string;
lifetime: number;
@ -41,66 +66,27 @@ export interface AuthConfig {
enableRegister: boolean;
minimalPasswordStrength: number;
};
github: {
clientID: string;
clientSecret: string;
};
google: {
clientID: string;
clientSecret: string;
apiKey: string;
};
gitlab: {
identifier: string;
providerName: string;
baseURL: string;
clientID: string;
clientSecret: string;
scope: GitlabScope;
}[];
// ToDo: tlsOptions exist in config.json.example. See https://nodejs.org/api/tls.html#tls_tls_connect_options_callback
ldap: LDAPConfig[];
saml: {
identifier: string;
providerName: string;
idpSsoUrl: string;
idpCert: string;
clientCert: string;
issuer: string;
identifierFormat: string;
disableRequestedAuthnContext: string;
groupAttribute: string;
requiredGroups?: string[];
externalGroups?: string[];
attribute: {
id: string;
username: string;
email: string;
};
}[];
oauth2: {
identifier: string;
providerName: string;
baseURL: string;
userProfileURL: string;
userProfileIdAttr: string;
userProfileUsernameAttr: string;
userProfileDisplayNameAttr: string;
userProfileEmailAttr: string;
tokenURL: string;
authorizationURL: string;
clientID: string;
clientSecret: string;
scope: string;
rolesClaim: string;
accessRole: string;
}[];
oidc: OidcConfig[];
}
const authSchema = Joi.object({
common: {
allowProfileEdits: Joi.boolean()
.default(true)
.optional()
.label('HD_AUTH_ALLOW_PROFILE_EDITS'),
allowChooseUsername: Joi.boolean()
.default(true)
.optional()
.label('HD_AUTH_ALLOW_CHOOSE_USERNAME'),
syncSource: Joi.string().optional().label('HD_AUTH_SYNC_SOURCE'),
},
session: {
secret: Joi.string().label('HD_SESSION_SECRET'),
lifetime: Joi.number()
.default(1209600000) // 14 * 24 * 60 * 60 * 1000ms = 14 days
.default(1209600) // 14 * 24 * 60 * 60s = 14 days
.optional()
.label('HD_SESSION_LIFETIME'),
},
@ -120,30 +106,6 @@ const authSchema = Joi.object({
.optional()
.label('HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH'),
},
github: {
clientID: Joi.string().optional().label('HD_AUTH_GITHUB_CLIENT_ID'),
clientSecret: Joi.string().optional().label('HD_AUTH_GITHUB_CLIENT_SECRET'),
},
google: {
clientID: Joi.string().optional().label('HD_AUTH_GOOGLE_CLIENT_ID'),
clientSecret: Joi.string().optional().label('HD_AUTH_GOOGLE_CLIENT_SECRET'),
apiKey: Joi.string().optional().label('HD_AUTH_GOOGLE_APP_KEY'),
},
gitlab: Joi.array()
.items(
Joi.object({
identifier: Joi.string(),
providerName: Joi.string().default('Gitlab').optional(),
baseURL: Joi.string(),
clientID: Joi.string(),
clientSecret: Joi.string(),
scope: Joi.string()
.valid(...Object.values(GitlabScope))
.default(GitlabScope.READ_USER)
.optional(),
}).optional(),
)
.optional(),
ldap: Joi.array()
.items(
Joi.object({
@ -157,107 +119,49 @@ const authSchema = Joi.object({
searchAttributes: Joi.array().items(Joi.string()).optional(),
userIdField: Joi.string().default('uid').optional(),
displayNameField: Joi.string().default('displayName').optional(),
emailField: Joi.string().default('mail').optional(),
profilePictureField: Joi.string().default('jpegPhoto').optional(),
tlsCaCerts: Joi.array().items(Joi.string()).optional(),
}).optional(),
)
.optional(),
saml: Joi.array()
oidc: Joi.array()
.items(
Joi.object({
identifier: Joi.string(),
providerName: Joi.string().default('SAML').optional(),
idpSsoUrl: Joi.string(),
idpCert: Joi.string(),
clientCert: Joi.string().optional(),
issuer: Joi.string().optional(),
identifierFormat: Joi.string()
.default('urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress')
.optional(),
disableRequestedAuthnContext: Joi.boolean().default(false).optional(),
groupAttribute: Joi.string().optional(),
requiredGroups: Joi.array().items(Joi.string()).optional(),
externalGroups: Joi.array().items(Joi.string()).optional(),
attribute: {
id: Joi.string().default('NameId').optional(),
username: Joi.string().default('NameId').optional(),
local: Joi.string().default('NameId').optional(),
},
}).optional(),
)
.optional(),
oauth2: Joi.array()
.items(
Joi.object({
identifier: Joi.string(),
providerName: Joi.string().default('OAuth2').optional(),
baseURL: Joi.string(),
userProfileURL: Joi.string(),
userProfileIdAttr: Joi.string().optional(),
userProfileUsernameAttr: Joi.string(),
userProfileDisplayNameAttr: Joi.string(),
userProfileEmailAttr: Joi.string(),
tokenURL: Joi.string(),
authorizationURL: Joi.string(),
providerName: Joi.string().default('OpenID Connect').optional(),
issuer: Joi.string(),
clientID: Joi.string(),
clientSecret: Joi.string(),
scope: Joi.string().optional(),
rolesClaim: Joi.string().optional(),
accessRole: Joi.string().optional(),
theme: Joi.string()
.valid(...Object.values(Theme))
.optional(),
authorizeUrl: Joi.string().optional(),
tokenUrl: Joi.string().optional(),
userinfoUrl: Joi.string().optional(),
scope: Joi.string().default('openid profile email').optional(),
userIdField: Joi.string().default('sub').optional(),
userNameField: Joi.string().default('preferred_username').optional(),
displayNameField: Joi.string().default('name').optional(),
profilePictureField: Joi.string().default('picture').optional(),
emailField: Joi.string().default('email').optional(),
}).optional(),
)
.optional(),
});
export default registerAs('authConfig', () => {
const gitlabNames = (
toArrayConfig(process.env.HD_AUTH_GITLABS, ',') ?? []
).map((name) => name.toUpperCase());
if (gitlabNames.length !== 0) {
throw new Error(
"GitLab auth is currently not yet supported. Please don't configure it",
);
}
ensureNoDuplicatesExist('GitLab', gitlabNames);
const ldapNames = (
toArrayConfig(process.env.HD_AUTH_LDAP_SERVERS, ',') ?? []
).map((name) => name.toUpperCase());
ensureNoDuplicatesExist('LDAP', ldapNames);
const samlNames = (toArrayConfig(process.env.HD_AUTH_SAMLS, ',') ?? []).map(
(name) => name.toUpperCase(),
);
if (samlNames.length !== 0) {
throw new Error(
"SAML auth is currently not yet supported. Please don't configure it",
);
}
ensureNoDuplicatesExist('SAML', samlNames);
const oauth2Names = (
toArrayConfig(process.env.HD_AUTH_OAUTH2S, ',') ?? []
const oidcNames = (
toArrayConfig(process.env.HD_AUTH_OIDC_SERVERS, ',') ?? []
).map((name) => name.toUpperCase());
if (oauth2Names.length !== 0) {
throw new Error(
"OAuth2 auth is currently not yet supported. Please don't configure it",
);
}
ensureNoDuplicatesExist('OAuth2', oauth2Names);
ensureNoDuplicatesExist('OIDC', oidcNames);
const gitlabs = gitlabNames.map((gitlabName) => {
return {
identifier: gitlabName,
providerName: process.env[`HD_AUTH_GITLAB_${gitlabName}_PROVIDER_NAME`],
baseURL: process.env[`HD_AUTH_GITLAB_${gitlabName}_BASE_URL`],
clientID: process.env[`HD_AUTH_GITLAB_${gitlabName}_CLIENT_ID`],
clientSecret: process.env[`HD_AUTH_GITLAB_${gitlabName}_CLIENT_SECRET`],
scope: process.env[`HD_AUTH_GITLAB_${gitlabName}_SCOPE`],
version: process.env[`HD_AUTH_GITLAB_${gitlabName}_GITLAB_VERSION`],
};
});
const ldaps = ldapNames.map((ldapName) => {
const ldapInstances = ldapNames.map((ldapName) => {
const caFiles = toArrayConfig(
process.env[`HD_AUTH_LDAP_${ldapName}_TLS_CERT_PATHS`],
',',
@ -271,7 +175,7 @@ export default registerAs('authConfig', () => {
});
}
return {
identifier: ldapName,
identifier: ldapName.toLowerCase(),
providerName: process.env[`HD_AUTH_LDAP_${ldapName}_PROVIDER_NAME`],
url: process.env[`HD_AUTH_LDAP_${ldapName}_URL`],
bindDn: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_DN`],
@ -285,92 +189,45 @@ export default registerAs('authConfig', () => {
userIdField: process.env[`HD_AUTH_LDAP_${ldapName}_USER_ID_FIELD`],
displayNameField:
process.env[`HD_AUTH_LDAP_${ldapName}_DISPLAY_NAME_FIELD`],
emailField: process.env[`HD_AUTH_LDAP_${ldapName}_EMAIL_FIELD`],
profilePictureField:
process.env[`HD_AUTH_LDAP_${ldapName}_PROFILE_PICTURE_FIELD`],
tlsCaCerts: tlsCaCerts,
};
});
const samls = samlNames.map((samlName) => {
return {
identifier: samlName,
providerName: process.env[`HD_AUTH_SAML_${samlName}_PROVIDER_NAME`],
idpSsoUrl: process.env[`HD_AUTH_SAML_${samlName}_IDP_SSO_URL`],
idpCert: process.env[`HD_AUTH_SAML_${samlName}_IDP_CERT`],
clientCert: process.env[`HD_AUTH_SAML_${samlName}_CLIENT_CERT`],
// ToDo: (default: config.serverURL) will be build on-the-fly in the config/index.js from domain, urlAddPort and urlPath.
// https://github.com/hedgedoc/hedgedoc/issues/5043
issuer: process.env[`HD_AUTH_SAML_${samlName}_ISSUER`],
identifierFormat:
process.env[`HD_AUTH_SAML_${samlName}_IDENTIFIER_FORMAT`],
disableRequestedAuthnContext:
process.env[`HD_AUTH_SAML_${samlName}_DISABLE_REQUESTED_AUTHN_CONTEXT`],
groupAttribute: process.env[`HD_AUTH_SAML_${samlName}_GROUP_ATTRIBUTE`],
requiredGroups: toArrayConfig(
process.env[`HD_AUTH_SAML_${samlName}_REQUIRED_GROUPS`],
'|',
),
externalGroups: toArrayConfig(
process.env[`HD_AUTH_SAML_${samlName}_EXTERNAL_GROUPS`],
'|',
),
attribute: {
id: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_ID`],
username: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_USERNAME`],
local: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_LOCAL`],
},
};
});
const oidcInstances = oidcNames.map((oidcName) => ({
identifier: oidcName.toLowerCase(),
providerName: process.env[`HD_AUTH_OIDC_${oidcName}_PROVIDER_NAME`],
issuer: process.env[`HD_AUTH_OIDC_${oidcName}_ISSUER`],
clientID: process.env[`HD_AUTH_OIDC_${oidcName}_CLIENT_ID`],
clientSecret: process.env[`HD_AUTH_OIDC_${oidcName}_CLIENT_SECRET`],
theme: process.env[`HD_AUTH_OIDC_${oidcName}_THEME`],
authorizeUrl: process.env[`HD_AUTH_OIDC_${oidcName}_AUTHORIZE_URL`],
tokenUrl: process.env[`HD_AUTH_OIDC_${oidcName}_TOKEN_URL`],
userinfoUrl: process.env[`HD_AUTH_OIDC_${oidcName}_USERINFO_URL`],
scope: process.env[`HD_AUTH_OIDC_${oidcName}_SCOPE`],
userIdField: process.env[`HD_AUTH_OIDC_${oidcName}_USER_ID_FIELD`],
userNameField: process.env[`HD_AUTH_OIDC_${oidcName}_USER_NAME_FIELD`],
displayNameField:
process.env[`HD_AUTH_OIDC_${oidcName}_DISPLAY_NAME_FIELD`],
profilePictureField:
process.env[`HD_AUTH_OIDC_${oidcName}_PROFILE_PICTURE_FIELD`],
emailField: process.env[`HD_AUTH_OIDC_${oidcName}_EMAIL_FIELD`],
}));
const oauth2s = oauth2Names.map((oauth2Name) => {
return {
identifier: oauth2Name,
providerName: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_PROVIDER_NAME`],
baseURL: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_BASE_URL`],
userProfileURL:
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_URL`],
userProfileIdAttr:
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_ID_ATTR`],
userProfileUsernameAttr:
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_USERNAME_ATTR`],
userProfileDisplayNameAttr:
process.env[
`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_DISPLAY_NAME_ATTR`
],
userProfileEmailAttr:
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_EMAIL_ATTR`],
tokenURL: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_TOKEN_URL`],
authorizationURL:
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_AUTHORIZATION_URL`],
clientID: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_CLIENT_ID`],
clientSecret: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_CLIENT_SECRET`],
scope: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_SCOPE`],
rolesClaim: process.env[`HD_AUTH_OAUTH2_${oauth2Name}`],
accessRole: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_ACCESS_ROLE`],
};
});
if (
process.env.HD_AUTH_GITHUB_CLIENT_ID !== undefined ||
process.env.HD_AUTH_GITHUB_CLIENT_SECRET !== undefined
) {
throw new Error(
"GitHub config is currently not yet supported. Please don't configure it",
);
}
if (
process.env.HD_AUTH_GOOGLE_CLIENT_ID !== undefined ||
process.env.HD_AUTH_GOOGLE_CLIENT_SECRET !== undefined ||
process.env.HD_AUTH_GOOGLE_APP_KEY !== undefined
) {
throw new Error(
"Google config is currently not yet supported. Please don't configure it",
);
let syncSource = process.env.HD_AUTH_SYNC_SOURCE;
if (syncSource !== undefined) {
syncSource = syncSource.toLowerCase();
}
const authConfig = authSchema.validate(
{
common: {
allowProfileEdits: process.env.HD_AUTH_ALLOW_PROFILE_EDITS,
allowChooseUsername: process.env.HD_AUTH_ALLOW_CHOOSE_USERNAME,
syncSource: syncSource,
},
session: {
secret: process.env.HD_SESSION_SECRET,
lifetime: parseOptionalNumber(process.env.HD_SESSION_LIFETIME),
@ -382,19 +239,8 @@ export default registerAs('authConfig', () => {
process.env.HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH,
),
},
github: {
clientID: process.env.HD_AUTH_GITHUB_CLIENT_ID,
clientSecret: process.env.HD_AUTH_GITHUB_CLIENT_SECRET,
},
google: {
clientID: process.env.HD_AUTH_GOOGLE_CLIENT_ID,
clientSecret: process.env.HD_AUTH_GOOGLE_CLIENT_SECRET,
apiKey: process.env.HD_AUTH_GOOGLE_APP_KEY,
},
gitlab: gitlabs,
ldap: ldaps,
saml: samls,
oauth2: oauth2s,
ldap: ldapInstances,
oidc: oidcInstances,
},
{
abortEarly: false,
@ -404,14 +250,6 @@ export default registerAs('authConfig', () => {
if (authConfig.error) {
const errorMessages = authConfig.error.details
.map((detail) => detail.message)
.map((error) =>
replaceAuthErrorsWithEnvironmentVariables(
error,
'gitlab',
'HD_AUTH_GITLAB_',
gitlabNames,
),
)
.map((error) =>
replaceAuthErrorsWithEnvironmentVariables(
error,
@ -423,17 +261,9 @@ export default registerAs('authConfig', () => {
.map((error) =>
replaceAuthErrorsWithEnvironmentVariables(
error,
'saml',
'HD_AUTH_SAML_',
samlNames,
),
)
.map((error) =>
replaceAuthErrorsWithEnvironmentVariables(
error,
'oauth2',
'HD_AUTH_OAUTH2_',
oauth2Names,
'oidc',
'HD_AUTH_OIDC_',
oidcNames,
),
);
throw new Error(buildErrorMessage(errorMessages));

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -10,6 +10,10 @@ import { AuthConfig } from '../auth.config';
export function createDefaultMockAuthConfig(): AuthConfig {
return {
common: {
allowProfileEdits: true,
allowChooseUsername: true,
},
session: {
secret: 'my_secret',
lifetime: 1209600000,
@ -19,19 +23,8 @@ export function createDefaultMockAuthConfig(): AuthConfig {
enableRegister: true,
minimalPasswordStrength: 2,
},
github: {
clientID: '',
clientSecret: '',
},
google: {
clientID: '',
clientSecret: '',
apiKey: '',
},
gitlab: [],
ldap: [],
saml: [],
oauth2: [],
oidc: [],
};
}

View 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',
}

View file

@ -67,16 +67,6 @@ describe('config utils', () => {
});
});
describe('replaceAuthErrorsWithEnvironmentVariables', () => {
it('"gitlab[0].scope', () => {
expect(
replaceAuthErrorsWithEnvironmentVariables(
'"gitlab[0].scope',
'gitlab',
'HD_AUTH_GITLAB_',
['test'],
),
).toEqual('"HD_AUTH_GITLAB_test_SCOPE');
});
it('"ldap[0].url', () => {
expect(
replaceAuthErrorsWithEnvironmentVariables(

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -66,61 +66,24 @@ export function replaceAuthErrorsWithEnvironmentVariables(
newMessage = newMessage.replace('.providerName', '_PROVIDER_NAME');
newMessage = newMessage.replace('.baseURL', '_BASE_URL');
newMessage = newMessage.replace('.clientID', '_CLIENT_ID');
newMessage = newMessage.replace('.clientSecret', '_CLIENT_SECRET');
newMessage = newMessage.replace('.scope', '_SCOPE');
newMessage = newMessage.replace('.version', '_GITLAB_VERSION');
newMessage = newMessage.replace('.url', '_URL');
newMessage = newMessage.replace('.clientSecret', '_CLIENT_SECRET');
newMessage = newMessage.replace('.bindDn', '_BIND_DN');
newMessage = newMessage.replace('.bindCredentials', '_BIND_CREDENTIALS');
newMessage = newMessage.replace('.searchBase', '_SEARCH_BASE');
newMessage = newMessage.replace('.searchFilter', '_SEARCH_FILTER');
newMessage = newMessage.replace('.searchAttributes', '_SEARCH_ATTRIBUTES');
newMessage = newMessage.replace('.userIdField', '_USER_ID_FIELD');
newMessage = newMessage.replace('.userNameField', '_USER_NAME_FIELD');
newMessage = newMessage.replace('.displayNameField', '_DISPLAY_NAME_FIELD');
newMessage = newMessage.replace('.emailField', '_EMAIL_FIELD');
newMessage = newMessage.replace(
'.profilePictureField',
'_PROFILE_PICTURE_FIELD',
);
newMessage = newMessage.replace('.tlsCaCerts', '_TLS_CERT_PATHS');
newMessage = newMessage.replace('.idpSsoUrl', '_IDP_SSO_URL');
newMessage = newMessage.replace('.idpCert', '_IDP_CERT');
newMessage = newMessage.replace('.clientCert', '_CLIENT_CERT');
newMessage = newMessage.replace('.issuer', '_ISSUER');
newMessage = newMessage.replace('.identifierFormat', '_IDENTIFIER_FORMAT');
newMessage = newMessage.replace(
'.disableRequestedAuthnContext',
'_DISABLE_REQUESTED_AUTHN_CONTEXT',
);
newMessage = newMessage.replace('.groupAttribute', '_GROUP_ATTRIBUTE');
newMessage = newMessage.replace('.requiredGroups', '_REQUIRED_GROUPS');
newMessage = newMessage.replace('.externalGroups', '_EXTERNAL_GROUPS');
newMessage = newMessage.replace('.attribute.id', '_ATTRIBUTE_ID');
newMessage = newMessage.replace(
'.attribute.username',
'_ATTRIBUTE_USERNAME',
);
newMessage = newMessage.replace('.attribute.local', '_ATTRIBUTE_LOCAL');
newMessage = newMessage.replace('.userProfileURL', '_USER_PROFILE_URL');
newMessage = newMessage.replace(
'.userProfileIdAttr',
'_USER_PROFILE_ID_ATTR',
);
newMessage = newMessage.replace(
'.userProfileUsernameAttr',
'_USER_PROFILE_USERNAME_ATTR',
);
newMessage = newMessage.replace(
'.userProfileDisplayNameAttr',
'_USER_PROFILE_DISPLAY_NAME_ATTR',
);
newMessage = newMessage.replace(
'.userProfileEmailAttr',
'_USER_PROFILE_EMAIL_ATTR',
);
newMessage = newMessage.replace('.tokenURL', '_TOKEN_URL');
newMessage = newMessage.replace('.authorizationURL', '_AUTHORIZATION_URL');
newMessage = newMessage.replace('.rolesClaim', '_ROLES_CLAIM');
newMessage = newMessage.replace('.accessRole', '_ACCESS_ROLE');
newMessage = newMessage.replace('.theme', '_THEME');
}
return newMessage;
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -16,29 +16,15 @@ import {
import { URL } from 'url';
import { GuestAccess } from '../config/guest_access.enum';
import { ProviderType } from '../identity/provider-type.enum';
import { ServerVersion } from '../monitoring/server-status.dto';
import { BaseDto } from '../utils/base.dto.';
export enum AuthProviderType {
LOCAL = 'local',
LDAP = 'ldap',
SAML = 'saml',
OAUTH2 = 'oauth2',
GITLAB = 'gitlab',
GITHUB = 'github',
GOOGLE = 'google',
}
export type AuthProviderTypeWithCustomName =
| AuthProviderType.LDAP
| AuthProviderType.OAUTH2
| AuthProviderType.SAML
| AuthProviderType.GITLAB;
| ProviderType.LDAP
| ProviderType.OIDC;
export type AuthProviderTypeWithoutCustomName =
| AuthProviderType.LOCAL
| AuthProviderType.GITHUB
| AuthProviderType.GOOGLE;
export type AuthProviderTypeWithoutCustomName = ProviderType.LOCAL;
export class AuthProviderWithoutCustomNameDto extends BaseDto {
/**
@ -70,6 +56,14 @@ export class AuthProviderWithCustomNameDto extends BaseDto {
*/
@IsString()
providerName: string;
/**
* The theme to apply for the login button.
* @example gitlab
*/
@IsOptional()
@IsString()
theme?: string;
}
export type AuthProviderDto =
@ -137,6 +131,18 @@ export class FrontendConfigDto extends BaseDto {
@IsBoolean()
allowRegister: boolean;
/**
* Are users allowed to edit their profile information?
*/
@IsBoolean()
allowProfileEdits: boolean;
/**
* Are users allowed to choose their username when signing up via OIDC?
*/
@IsBoolean()
allowChooseUsername: boolean;
/**
* Which auth providers are enabled and how are they configured?
*/

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -12,13 +12,12 @@ import { AuthConfig } from '../config/auth.config';
import { CustomizationConfig } from '../config/customization.config';
import { DefaultAccessLevel } from '../config/default-access-level.enum';
import { ExternalServicesConfig } from '../config/external-services.config';
import { GitlabScope } from '../config/gitlab.enum';
import { GuestAccess } from '../config/guest_access.enum';
import { Loglevel } from '../config/loglevel.enum';
import { NoteConfig } from '../config/note.config';
import { ProviderType } from '../identity/provider-type.enum';
import { LoggerModule } from '../logger/logger.module';
import { getServerVersionFromPackageJson } from '../utils/serverVersion';
import { AuthProviderType } from './frontend-config.dto';
import { FrontendConfigService } from './frontend-config.service';
/* eslint-disable
@ -28,6 +27,11 @@ import { FrontendConfigService } from './frontend-config.service';
describe('FrontendConfigService', () => {
const domain = 'http://md.example.com';
const emptyAuthConfig: AuthConfig = {
common: {
allowProfileEdits: true,
allowChooseUsername: true,
syncSource: undefined,
},
session: {
secret: 'my-secret',
lifetime: 1209600000,
@ -37,41 +41,11 @@ describe('FrontendConfigService', () => {
enableRegister: false,
minimalPasswordStrength: 2,
},
github: {
clientID: undefined,
clientSecret: undefined,
},
google: {
clientID: undefined,
clientSecret: undefined,
apiKey: undefined,
},
gitlab: [],
ldap: [],
saml: [],
oauth2: [],
oidc: [],
};
describe('getAuthProviders', () => {
const github: AuthConfig['github'] = {
clientID: 'githubTestId',
clientSecret: 'githubTestSecret',
};
const google: AuthConfig['google'] = {
clientID: 'googleTestId',
clientSecret: 'googleTestSecret',
apiKey: 'googleTestKey',
};
const gitlab: AuthConfig['gitlab'] = [
{
identifier: 'gitlabTestIdentifier',
providerName: 'gitlabTestName',
baseURL: 'gitlabTestUrl',
clientID: 'gitlabTestId',
clientSecret: 'gitlabTestSecret',
scope: GitlabScope.API,
},
];
const ldap: AuthConfig['ldap'] = [
{
identifier: 'ldapTestIdentifier',
@ -83,58 +57,28 @@ describe('FrontendConfigService', () => {
searchFilter: 'ldapTestSearchFilter',
searchAttributes: ['ldapTestSearchAttribute'],
userIdField: 'ldapTestUserId',
emailField: 'ldapEmailField',
displayNameField: 'ldapTestDisplayName',
profilePictureField: 'ldapTestProfilePicture',
tlsCaCerts: ['ldapTestTlsCa'],
},
];
const saml: AuthConfig['saml'] = [
const oidc: AuthConfig['oidc'] = [
{
identifier: 'samlTestIdentifier',
providerName: 'samlTestName',
idpSsoUrl: 'samlTestUrl',
idpCert: 'samlTestCert',
clientCert: 'samlTestClientCert',
issuer: 'samlTestIssuer',
identifierFormat: 'samlTestUrl',
disableRequestedAuthnContext: 'samlTestUrl',
groupAttribute: 'samlTestUrl',
requiredGroups: ['samlTestUrl'],
externalGroups: ['samlTestUrl'],
attribute: {
id: 'samlTestUrl',
username: 'samlTestUrl',
email: 'samlTestUrl',
},
identifier: 'oidcTestIdentifier',
providerName: 'oidcTestProviderName',
issuer: 'oidcTestIssuer',
clientID: 'oidcTestId',
clientSecret: 'oidcTestSecret',
scope: 'openid profile email',
userIdField: '',
userNameField: '',
displayNameField: '',
profilePictureField: '',
emailField: '',
},
];
const oauth2: AuthConfig['oauth2'] = [
{
identifier: 'oauth2Testidentifier',
providerName: 'oauth2TestName',
baseURL: 'oauth2TestUrl',
userProfileURL: 'oauth2TestProfileUrl',
userProfileIdAttr: 'oauth2TestProfileId',
userProfileUsernameAttr: 'oauth2TestProfileUsername',
userProfileDisplayNameAttr: 'oauth2TestProfileDisplay',
userProfileEmailAttr: 'oauth2TestProfileEmail',
tokenURL: 'oauth2TestTokenUrl',
authorizationURL: 'oauth2TestAuthUrl',
clientID: 'oauth2TestId',
clientSecret: 'oauth2TestSecret',
scope: 'oauth2TestScope',
rolesClaim: 'oauth2TestRoles',
accessRole: 'oauth2TestAccess',
},
];
for (const authConfigConfigured of [
github,
google,
gitlab,
ldap,
saml,
oauth2,
]) {
for (const authConfigConfigured of [ldap, oidc]) {
it(`works with ${JSON.stringify(authConfigConfigured)}`, async () => {
const appConfig: AppConfig = {
baseUrl: domain,
@ -182,83 +126,41 @@ describe('FrontendConfigService', () => {
}).compile();
const service = module.get(FrontendConfigService);
const config = await service.getFrontendConfig();
if (authConfig.google.clientID) {
expect(config.authProviders).toContainEqual({
type: AuthProviderType.GOOGLE,
});
}
if (authConfig.github.clientID) {
expect(config.authProviders).toContainEqual({
type: AuthProviderType.GITHUB,
});
}
if (authConfig.local.enableLogin) {
expect(config.authProviders).toContainEqual({
type: AuthProviderType.LOCAL,
type: ProviderType.LOCAL,
});
}
expect(
config.authProviders.filter(
(provider) => provider.type === AuthProviderType.GITLAB,
).length,
).toEqual(authConfig.gitlab.length);
expect(
config.authProviders.filter(
(provider) => provider.type === AuthProviderType.LDAP,
(provider) => provider.type === ProviderType.LDAP,
).length,
).toEqual(authConfig.ldap.length);
expect(
config.authProviders.filter(
(provider) => provider.type === AuthProviderType.SAML,
(provider) => provider.type === ProviderType.OIDC,
).length,
).toEqual(authConfig.saml.length);
expect(
config.authProviders.filter(
(provider) => provider.type === AuthProviderType.OAUTH2,
).length,
).toEqual(authConfig.oauth2.length);
if (authConfig.gitlab.length > 0) {
expect(
config.authProviders.find(
(provider) => provider.type === AuthProviderType.GITLAB,
),
).toEqual({
type: AuthProviderType.GITLAB,
providerName: authConfig.gitlab[0].providerName,
identifier: authConfig.gitlab[0].identifier,
});
}
).toEqual(authConfig.oidc.length);
if (authConfig.ldap.length > 0) {
expect(
config.authProviders.find(
(provider) => provider.type === AuthProviderType.LDAP,
(provider) => provider.type === ProviderType.LDAP,
),
).toEqual({
type: AuthProviderType.LDAP,
type: ProviderType.LDAP,
providerName: authConfig.ldap[0].providerName,
identifier: authConfig.ldap[0].identifier,
});
}
if (authConfig.saml.length > 0) {
if (authConfig.oidc.length > 0) {
expect(
config.authProviders.find(
(provider) => provider.type === AuthProviderType.SAML,
(provider) => provider.type === ProviderType.OIDC,
),
).toEqual({
type: AuthProviderType.SAML,
providerName: authConfig.saml[0].providerName,
identifier: authConfig.saml[0].identifier,
});
}
if (authConfig.oauth2.length > 0) {
expect(
config.authProviders.find(
(provider) => provider.type === AuthProviderType.OAUTH2,
),
).toEqual({
type: AuthProviderType.OAUTH2,
providerName: authConfig.oauth2[0].providerName,
identifier: authConfig.oauth2[0].identifier,
type: ProviderType.OIDC,
providerName: authConfig.oidc[0].providerName,
identifier: authConfig.oidc[0].identifier,
});
}
});

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -15,11 +15,11 @@ import externalServicesConfiguration, {
ExternalServicesConfig,
} from '../config/external-services.config';
import noteConfiguration, { NoteConfig } from '../config/note.config';
import { ProviderType } from '../identity/provider-type.enum';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { getServerVersionFromPackageJson } from '../utils/serverVersion';
import {
AuthProviderDto,
AuthProviderType,
BrandingDto,
FrontendConfigDto,
SpecialUrlsDto,
@ -47,6 +47,8 @@ export class FrontendConfigService {
return {
guestAccess: this.noteConfig.guestAccess,
allowRegister: this.authConfig.local.enableRegister,
allowProfileEdits: this.authConfig.common.allowProfileEdits,
allowChooseUsername: this.authConfig.common.allowChooseUsername,
authProviders: this.getAuthProviders(),
branding: this.getBranding(),
maxDocumentLength: this.noteConfig.maxDocumentLength,
@ -63,45 +65,22 @@ export class FrontendConfigService {
const providers: AuthProviderDto[] = [];
if (this.authConfig.local.enableLogin) {
providers.push({
type: AuthProviderType.LOCAL,
type: ProviderType.LOCAL,
});
}
if (this.authConfig.github.clientID) {
providers.push({
type: AuthProviderType.GITHUB,
});
}
if (this.authConfig.google.clientID) {
providers.push({
type: AuthProviderType.GOOGLE,
});
}
this.authConfig.gitlab.forEach((gitLabEntry) => {
providers.push({
type: AuthProviderType.GITLAB,
providerName: gitLabEntry.providerName,
identifier: gitLabEntry.identifier,
});
});
this.authConfig.ldap.forEach((ldapEntry) => {
providers.push({
type: AuthProviderType.LDAP,
type: ProviderType.LDAP,
providerName: ldapEntry.providerName,
identifier: ldapEntry.identifier,
});
});
this.authConfig.oauth2.forEach((oauth2Entry) => {
this.authConfig.oidc.forEach((openidConnectEntry) => {
providers.push({
type: AuthProviderType.OAUTH2,
providerName: oauth2Entry.providerName,
identifier: oauth2Entry.identifier,
});
});
this.authConfig.saml.forEach((samlEntry) => {
providers.push({
type: AuthProviderType.SAML,
providerName: samlEntry.providerName,
identifier: samlEntry.identifier,
type: ProviderType.OIDC,
providerName: openidConnectEntry.providerName,
identifier: openidConnectEntry.identifier,
theme: openidConnectEntry.theme,
});
});
return providers;

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -41,21 +41,14 @@ export class Identity {
providerType: string;
/**
* The name of the provider.
* Only set if there are multiple provider of that type (e.g. gitlab)
* The identifier of the provider.
* Only set if there are multiple providers of that type (e.g. OIDC)
*/
@Column({
nullable: true,
type: 'text',
})
providerName: string | null;
/**
* If the identity should be used as the sync source.
* See [authentication doc](../../docs/content/dev/user_profiles.md) for clarification
*/
@Column()
syncSource: boolean;
providerIdentifier: string | null;
/**
* When the identity was created.
@ -78,15 +71,6 @@ export class Identity {
})
providerUserId: string | null;
/**
* Token used to access the OAuth provider in the users name.
*/
@Column({
nullable: true,
type: 'text',
})
oAuthAccessToken: string | null;
/**
* The hash of the password
* Only set when the type of the identity is local
@ -100,15 +84,13 @@ export class Identity {
public static create(
user: User,
providerType: ProviderType,
syncSource: boolean,
providerIdentifier: string | null,
): Omit<Identity, 'id' | 'createdAt' | 'updatedAt'> {
const newIdentity = new Identity();
newIdentity.user = Promise.resolve(user);
newIdentity.providerType = providerType;
newIdentity.providerName = null;
newIdentity.syncSource = syncSource;
newIdentity.providerIdentifier = providerIdentifier;
newIdentity.providerUserId = null;
newIdentity.oAuthAccessToken = null;
newIdentity.passwordHash = null;
return newIdentity;
}

View file

@ -1,10 +1,9 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggerModule } from '../logger/logger.module';
@ -12,24 +11,18 @@ import { User } from '../users/user.entity';
import { UsersModule } from '../users/users.module';
import { Identity } from './identity.entity';
import { IdentityService } from './identity.service';
import { LdapAuthGuard, LdapStrategy } from './ldap/ldap.strategy';
import { LocalAuthGuard, LocalStrategy } from './local/local.strategy';
import { LdapService } from './ldap/ldap.service';
import { LocalService } from './local/local.service';
import { OidcService } from './oidc/oidc.service';
@Module({
imports: [
TypeOrmModule.forFeature([Identity, User]),
UsersModule,
PassportModule,
LoggerModule,
],
controllers: [],
providers: [
IdentityService,
LocalStrategy,
LdapStrategy,
LdapAuthGuard,
LocalAuthGuard,
],
exports: [IdentityService, LocalStrategy, LdapStrategy],
providers: [IdentityService, LdapService, LocalService, OidcService],
exports: [IdentityService, LdapService, LocalService, OidcService],
})
export class IdentityModule {}

View file

@ -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);
});
});
});
});

View file

@ -3,57 +3,47 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
OptionsGraph,
OptionsType,
zxcvbnAsync,
zxcvbnOptions,
} from '@zxcvbn-ts/core';
import {
adjacencyGraphs,
dictionary as zxcvbnCommonDictionary,
} from '@zxcvbn-ts/language-common';
import {
dictionary as zxcvbnEnDictionary,
translations as zxcvbnEnTranslations,
} from '@zxcvbn-ts/language-en';
import { Repository } from 'typeorm';
Inject,
Injectable,
InternalServerErrorException,
} from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import authConfiguration, { AuthConfig } from '../config/auth.config';
import {
InvalidCredentialsError,
NoLocalIdentityError,
NotInDBError,
PasswordTooWeakError,
} from '../errors/errors';
import AuthConfiguration, { AuthConfig } from '../config/auth.config';
import { NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { FullUserInfoDto } from '../users/user-info.dto';
import { User } from '../users/user.entity';
import { checkPassword, hashPassword } from '../utils/password';
import { UsersService } from '../users/users.service';
import { Identity } from './identity.entity';
import { PendingUserConfirmationDto } from './pending-user-confirmation.dto';
import { ProviderType } from './provider-type.enum';
import { getFirstIdentityFromUser } from './utils';
@Injectable()
export class IdentityService {
constructor(
private readonly logger: ConsoleLoggerService,
private usersService: UsersService,
@InjectDataSource()
private dataSource: DataSource,
@Inject(AuthConfiguration.KEY)
private authConfig: AuthConfig,
@InjectRepository(Identity)
private identityRepository: Repository<Identity>,
@Inject(authConfiguration.KEY)
private authConfig: AuthConfig,
) {
this.logger.setContext(IdentityService.name);
const options: OptionsType = {
dictionary: {
...zxcvbnCommonDictionary,
...zxcvbnEnDictionary,
},
graphs: adjacencyGraphs as OptionsGraph,
translations: zxcvbnEnTranslations,
};
zxcvbnOptions.setOptions(options);
}
/**
* Determines if the identity should be updated
*
* @param authProviderIdentifier The identifier of the auth source
* @return true if the authProviderIdentifier is the sync source, false otherwise
*/
mayUpdateIdentity(authProviderIdentifier: string): boolean {
return this.authConfig.common.syncSource === authProviderIdentifier;
}
/**
@ -61,15 +51,18 @@ export class IdentityService {
* Retrieve an identity by userId and providerType.
* @param {string} userId - the userId of the wanted identity
* @param {ProviderType} providerType - the providerType of the wanted identity
* @param {string} providerIdentifier - optional name of the provider if multiple exist
*/
async getIdentityFromUserIdAndProviderType(
userId: string,
providerType: ProviderType,
providerIdentifier?: string,
): Promise<Identity> {
const identity = await this.identityRepository.findOne({
where: {
providerUserId: userId,
providerType: providerType,
providerType,
providerIdentifier,
},
relations: ['user'],
});
@ -79,138 +72,81 @@ export class IdentityService {
return identity;
}
/**
* @async
* Update the given Identity with the given information
* @param {Identity} identity - the identity to update
* @param {string | undefined} displayName - the displayName to update the user with
* @param {string | undefined} email - the email to update the user with
* @param {string | undefined} profilePicture - the profilePicture to update the user with
*/
async updateIdentity(
identity: Identity,
displayName?: string,
email?: string,
profilePicture?: string,
): Promise<Identity> {
if (identity.syncSource) {
// The identity is the syncSource and the user should be changed accordingly
const user = await identity.user;
let shouldSave = false;
if (displayName) {
user.displayName = displayName;
shouldSave = true;
}
if (email) {
user.email = email;
shouldSave = true;
}
if (profilePicture) {
// ToDo: sync image (https://github.com/hedgedoc/hedgedoc/issues/5032)
}
if (shouldSave) {
identity.user = Promise.resolve(user);
return await this.identityRepository.save(identity);
}
}
return identity;
}
/**
* @async
* Create a new generic identity.
* @param {User} user - the user the identity should be added to
* @param {ProviderType} providerType - the providerType of the identity
* @param {string} userId - the userId the identity should have
* @param {string} providerIdentifier - the providerIdentifier of the identity
* @param {string} providerUserId - the userId the identity should have
* @return {Identity} the new local identity
*/
async createIdentity(
user: User,
providerType: ProviderType,
userId: string,
providerIdentifier: string,
providerUserId: string,
): Promise<Identity> {
const identity = Identity.create(user, providerType, false);
identity.providerUserId = userId;
const identity = Identity.create(user, providerType, providerIdentifier);
identity.providerUserId = providerUserId;
return await this.identityRepository.save(identity);
}
/**
* @async
* Create a new identity for internal auth
* @param {User} user - the user the identity should be added to
* @param {string} password - the password the identity should have
* @return {Identity} the new local identity
* Creates a new user with the given user data and the session data.
*
* @param {FullUserInfoDto} sessionUserData The user data from the session
* @param {PendingUserConfirmationDto} updatedUserData The updated user data from the API
* @param {ProviderType} authProviderType The type of the auth provider
* @param {string} authProviderIdentifier The identifier of the auth provider
* @param {string} providerUserId The id of the user in the auth system
*/
async createLocalIdentity(user: User, password: string): Promise<Identity> {
const identity = Identity.create(user, ProviderType.LOCAL, false);
identity.passwordHash = await hashPassword(password);
return await this.identityRepository.save(identity);
}
/**
* @async
* Update the internal password of the specified the user
* @param {User} user - the user, which identity should be updated
* @param {string} newPassword - the new password
* @throws {NoLocalIdentityError} the specified user has no internal identity
* @return {Identity} the changed identity
*/
async updateLocalPassword(
user: User,
newPassword: string,
async createUserWithIdentity(
sessionUserData: FullUserInfoDto,
updatedUserData: PendingUserConfirmationDto,
authProviderType: ProviderType,
authProviderIdentifier: string,
providerUserId: string,
): Promise<Identity> {
const internalIdentity: Identity | undefined =
await getFirstIdentityFromUser(user, ProviderType.LOCAL);
if (internalIdentity === undefined) {
this.logger.debug(
`The user with the username ${user.username} does not have a internal identity.`,
'updateLocalPassword',
);
throw new NoLocalIdentityError('This user has no internal identity.');
}
await this.checkPasswordStrength(newPassword);
internalIdentity.passwordHash = await hashPassword(newPassword);
return await this.identityRepository.save(internalIdentity);
}
const profileEditsAllowed = this.authConfig.common.allowProfileEdits;
const chooseUsernameAllowed = this.authConfig.common.allowChooseUsername;
/**
* @async
* Checks if the user and password combination matches
* @param {User} user - the user to use
* @param {string} password - the password to use
* @throws {InvalidCredentialsError} the password and user do not match
* @throws {NoLocalIdentityError} the specified user has no internal identity
*/
async checkLocalPassword(user: User, password: string): Promise<void> {
const internalIdentity: Identity | undefined =
await getFirstIdentityFromUser(user, ProviderType.LOCAL);
if (internalIdentity === undefined) {
this.logger.debug(
`The user with the username ${user.username} does not have an internal identity.`,
'checkLocalPassword',
);
throw new NoLocalIdentityError('This user has no internal identity.');
}
if (!(await checkPassword(password, internalIdentity.passwordHash ?? ''))) {
this.logger.debug(
`Password check for ${user.username} did not succeed.`,
'checkLocalPassword',
);
throw new InvalidCredentialsError('Password is not correct');
}
}
const username = (
chooseUsernameAllowed
? updatedUserData.username
: sessionUserData.username
) as Lowercase<string>;
const displayName = profileEditsAllowed
? updatedUserData.displayName
: sessionUserData.displayName;
const photoUrl = profileEditsAllowed
? updatedUserData.profilePicture
: sessionUserData.photoUrl;
/**
* @async
* Check if the password is strong enough.
* This check is performed against the minimalPasswordStrength of the {@link AuthConfig}.
* @param {string} password - the password to check
* @throws {PasswordTooWeakError} the password is too weak
*/
async checkPasswordStrength(password: string): Promise<void> {
const result = await zxcvbnAsync(password);
if (result.score < this.authConfig.local.minimalPasswordStrength) {
throw new PasswordTooWeakError();
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();
try {
const user = await this.usersService.createUser(
username,
displayName,
sessionUserData.email,
photoUrl,
);
const identity = await this.createIdentity(
user,
authProviderType,
authProviderIdentifier,
providerUserId,
);
await queryRunner.commitTransaction();
return identity;
} catch (error) {
this.logger.error(
'Error during user creation:' + String(error),
'createUserWithIdentity',
);
await queryRunner.rollbackTransaction();
throw new InternalServerErrorException();
}
}
}

View 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);
}
}

View file

@ -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);
}
}

View 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();
}
}
}

View file

@ -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;
}
}
}

View 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}` : ''}`;
}
}

View 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;
}

View file

@ -1,15 +1,12 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum ProviderType {
GUEST = 'guest',
LOCAL = 'local',
LDAP = 'ldap',
SAML = 'saml',
OAUTH2 = 'oauth2',
GITLAB = 'gitlab',
GITHUB = 'github',
GOOGLE = 'google',
OIDC = 'oidc',
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -10,13 +10,20 @@ import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';
import { CompleteRequest } from '../api/utils/request.type';
import { GuestAccess } from '../config/guest_access.enum';
import noteConfiguration, { NoteConfig } from '../config/note.config';
import { NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { SessionState } from '../sessions/session.service';
import { UsersService } from '../users/users.service';
import { ProviderType } from './provider-type.enum';
export type RequestWithSession = Request & {
session: SessionState;
};
/**
* This guard checks if a session is present.
@ -42,7 +49,9 @@ export class SessionGuard implements CanActivate {
const username = request.session?.username;
if (!username) {
if (this.noteConfig.guestAccess !== GuestAccess.DENY && request.session) {
request.session.authProvider = 'guest';
if (!request.session.authProviderType) {
request.session.authProviderType = ProviderType.GUEST;
}
return true;
}
this.logger.debug('The user has no session.');

View file

@ -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),
);
}

View file

@ -1,7 +1,12 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MariadbInit1725204784823 implements MigrationInterface {
name = 'MariadbInit1725204784823';
export class Init1725266569705 implements MigrationInterface {
name = 'Init1725266569705';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
@ -38,7 +43,7 @@ export class MariadbInit1725204784823 implements MigrationInterface {
`CREATE TABLE \`author\` (\`id\` int NOT NULL AUTO_INCREMENT, \`color\` int NOT NULL, \`userId\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
);
await queryRunner.query(
`CREATE TABLE \`identity\` (\`id\` int NOT NULL AUTO_INCREMENT, \`providerType\` varchar(255) NOT NULL, \`providerName\` text NULL, \`syncSource\` tinyint NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`providerUserId\` text NULL, \`oAuthAccessToken\` text NULL, \`passwordHash\` text NULL, \`userId\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
`CREATE TABLE \`identity\` (\`id\` int NOT NULL AUTO_INCREMENT, \`providerType\` varchar(255) NOT NULL, \`providerIdentifier\` text NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`providerUserId\` text NULL, \`passwordHash\` text NULL, \`userId\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
);
await queryRunner.query(
`CREATE TABLE \`public_auth_token\` (\`id\` int NOT NULL AUTO_INCREMENT, \`keyId\` varchar(255) NOT NULL, \`label\` varchar(255) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`hash\` varchar(255) NOT NULL, \`validUntil\` datetime NOT NULL, \`lastUsedAt\` date NULL, \`userId\` int NULL, UNIQUE INDEX \`IDX_b4c4b9179f72ef63c32248e83a\` (\`keyId\`), UNIQUE INDEX \`IDX_6450514886fa4182c889c076df\` (\`hash\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,

View file

@ -1,7 +1,12 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MigrationInterface, QueryRunner } from 'typeorm';
export class Init1725203299761 implements MigrationInterface {
name = 'Init1725203299761';
export class Init1725266697932 implements MigrationInterface {
name = 'Init1725266697932';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
@ -50,7 +55,7 @@ export class Init1725203299761 implements MigrationInterface {
`CREATE TABLE "author" ("id" SERIAL NOT NULL, "color" integer NOT NULL, "userId" integer, CONSTRAINT "PK_5a0e79799d372fe56f2f3fa6871" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "identity" ("id" SERIAL NOT NULL, "providerType" character varying NOT NULL, "providerName" text, "syncSource" boolean NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "providerUserId" text, "oAuthAccessToken" text, "passwordHash" text, "userId" integer, CONSTRAINT "PK_ff16a44186b286d5e626178f726" PRIMARY KEY ("id"))`,
`CREATE TABLE "identity" ("id" SERIAL NOT NULL, "providerType" character varying NOT NULL, "providerIdentifier" text, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "providerUserId" text, "passwordHash" text, "userId" integer, CONSTRAINT "PK_ff16a44186b286d5e626178f726" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "public_auth_token" ("id" SERIAL NOT NULL, "keyId" character varying NOT NULL, "label" character varying NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "hash" character varying NOT NULL, "validUntil" TIMESTAMP NOT NULL, "lastUsedAt" date, "userId" integer, CONSTRAINT "UQ_b4c4b9179f72ef63c32248e83ab" UNIQUE ("keyId"), CONSTRAINT "UQ_6450514886fa4182c889c076df6" UNIQUE ("hash"), CONSTRAINT "PK_1bdb7c2d237fb02d84fa75f48a5" PRIMARY KEY ("id"))`,

View file

@ -1,7 +1,12 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SqliteInit1725204990810 implements MigrationInterface {
name = 'SqliteInit1725204990810';
export class Init1725268109950 implements MigrationInterface {
name = 'Init1725268109950';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
@ -50,7 +55,7 @@ export class SqliteInit1725204990810 implements MigrationInterface {
`CREATE TABLE "author" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "color" integer NOT NULL, "userId" integer)`,
);
await queryRunner.query(
`CREATE TABLE "identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerName" text, "syncSource" boolean NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "oAuthAccessToken" text, "passwordHash" text, "userId" integer)`,
`CREATE TABLE "identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerIdentifier" text, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "passwordHash" text, "userId" integer)`,
);
await queryRunner.query(
`CREATE TABLE "public_auth_token" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "keyId" varchar NOT NULL, "label" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "hash" varchar NOT NULL, "validUntil" datetime NOT NULL, "lastUsedAt" date, "userId" integer, CONSTRAINT "UQ_b4c4b9179f72ef63c32248e83ab" UNIQUE ("keyId"), CONSTRAINT "UQ_6450514886fa4182c889c076df6" UNIQUE ("hash"))`,
@ -199,10 +204,10 @@ export class SqliteInit1725204990810 implements MigrationInterface {
`ALTER TABLE "temporary_author" RENAME TO "author"`,
);
await queryRunner.query(
`CREATE TABLE "temporary_identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerName" text, "syncSource" boolean NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "oAuthAccessToken" text, "passwordHash" text, "userId" integer, CONSTRAINT "FK_12915039d2868ab654567bf5181" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`,
`CREATE TABLE "temporary_identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerIdentifier" text, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "passwordHash" text, "userId" integer, CONSTRAINT "FK_12915039d2868ab654567bf5181" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`,
);
await queryRunner.query(
`INSERT INTO "temporary_identity"("id", "providerType", "providerName", "syncSource", "createdAt", "updatedAt", "providerUserId", "oAuthAccessToken", "passwordHash", "userId") SELECT "id", "providerType", "providerName", "syncSource", "createdAt", "updatedAt", "providerUserId", "oAuthAccessToken", "passwordHash", "userId" FROM "identity"`,
`INSERT INTO "temporary_identity"("id", "providerType", "providerIdentifier", "createdAt", "updatedAt", "providerUserId", "passwordHash", "userId") SELECT "id", "providerType", "providerIdentifier", "createdAt", "updatedAt", "providerUserId", "passwordHash", "userId" FROM "identity"`,
);
await queryRunner.query(`DROP TABLE "identity"`);
await queryRunner.query(
@ -343,10 +348,10 @@ export class SqliteInit1725204990810 implements MigrationInterface {
`ALTER TABLE "identity" RENAME TO "temporary_identity"`,
);
await queryRunner.query(
`CREATE TABLE "identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerName" text, "syncSource" boolean NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "oAuthAccessToken" text, "passwordHash" text, "userId" integer)`,
`CREATE TABLE "identity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "providerType" varchar NOT NULL, "providerIdentifier" text, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "providerUserId" text, "passwordHash" text, "userId" integer)`,
);
await queryRunner.query(
`INSERT INTO "identity"("id", "providerType", "providerName", "syncSource", "createdAt", "updatedAt", "providerUserId", "oAuthAccessToken", "passwordHash", "userId") SELECT "id", "providerType", "providerName", "syncSource", "createdAt", "updatedAt", "providerUserId", "oAuthAccessToken", "passwordHash", "userId" FROM "temporary_identity"`,
`INSERT INTO "identity"("id", "providerType", "providerIdentifier", "createdAt", "updatedAt", "providerUserId", "passwordHash", "userId") SELECT "id", "providerType", "providerIdentifier", "createdAt", "updatedAt", "providerUserId", "passwordHash", "userId" FROM "temporary_identity"`,
);
await queryRunner.query(`DROP TABLE "temporary_identity"`);
await queryRunner.query(

View file

@ -11,6 +11,7 @@ import crypto from 'crypto';
import { Repository } from 'typeorm';
import appConfigMock from '../config/mock/app.config.mock';
import authConfigMock from '../config/mock/auth.config.mock';
import {
NotInDBError,
TokenNotValidError,
@ -54,7 +55,7 @@ describe('AuthService', () => {
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfigMock],
load: [appConfigMock, authConfigMock],
}),
PassportModule,
UsersModule,

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -69,7 +69,7 @@ dataSource
Author.create(1),
)) as Author;
const user = (await dataSource.manager.save(users[i])) as User;
const identity = Identity.create(user, ProviderType.LOCAL, false);
const identity = Identity.create(user, ProviderType.LOCAL, null);
identity.passwordHash = await hashPassword(password);
dataSource.manager.create(Identity, identity);
author.user = dataSource.manager.save(user);

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -17,15 +17,37 @@ import { DatabaseType } from '../config/database-type.enum';
import databaseConfiguration, {
DatabaseConfig,
} from '../config/database.config';
import { ProviderType } from '../identity/provider-type.enum';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { FullUserInfoDto } from '../users/user-info.dto';
import { HEDGEDOC_SESSION } from '../utils/session';
import { Username } from '../utils/username';
import { Session } from './session.entity';
export interface SessionState {
/** Details about the currently used session cookie */
cookie: unknown;
/** Contains the username if logged in completely, is undefined when not being logged in */
username?: Username;
authProvider: string;
/** The auth provider that is used for the current login or pending login */
authProviderType?: ProviderType;
/** The identifier of the auth provider that is used for the current login or pending login */
authProviderIdentifier?: string;
/** The id token to identify a user session with an OIDC auth provider, required for the logout */
oidcIdToken?: string;
/** The (random) OIDC code for verifying that OIDC responses match the OIDC requests */
oidcLoginCode?: string;
/** The user id as provided from the external auth provider, required for matching to a HedgeDoc identity */
providerUserId?: string;
/** The user data of the user that is currently being created */
newUserData?: FullUserInfoDto;
}
/**

View file

@ -1,11 +1,11 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsLowercase, IsString } from 'class-validator';
import { IsLowercase, IsOptional, IsString } from 'class-validator';
import { BaseDto } from '../utils/base.dto.';
import { Username } from '../utils/username';
@ -33,11 +33,12 @@ export class UserInfoDto extends BaseDto {
* URL of the profile picture
* @example "https://hedgedoc.example.com/uploads/johnsmith.png"
*/
@ApiProperty({
@ApiPropertyOptional({
format: 'uri',
})
@IsOptional()
@IsString()
photoUrl: string;
photoUrl?: string;
}
/**
@ -49,11 +50,21 @@ export class FullUserInfoDto extends UserInfoDto {
* Email address of the user
* @example "john.smith@example.com"
*/
@ApiProperty({
@ApiPropertyOptional({
format: 'email',
})
@IsOptional()
@IsString()
email: string;
email?: string;
}
export class FullUserInfoWithIdDto extends FullUserInfoDto {
/**
* The user's ID
* @example 42
*/
@IsString()
id: string;
}
export class UserLoginInfoDto extends UserInfoDto {

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -80,12 +80,14 @@ export class User {
public static create(
username: Username,
displayName: string,
email?: string,
photoUrl?: string,
): Omit<User, 'id' | 'createdAt' | 'updatedAt'> {
const newUser = new User();
newUser.username = username;
newUser.displayName = displayName;
newUser.photo = null;
newUser.email = null;
newUser.photo = photoUrl ?? null;
newUser.email = email ?? null;
newUser.ownedNotes = Promise.resolve([]);
newUser.publicAuthTokens = Promise.resolve([]);
newUser.identities = Promise.resolve([]);

View 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;
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -9,6 +9,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import appConfigMock from '../config/mock/app.config.mock';
import authConfigMock from '../config/mock/auth.config.mock';
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
import { LoggerModule } from '../logger/logger.module';
import { User } from './user.entity';
@ -30,7 +31,7 @@ describe('UsersService', () => {
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfigMock],
load: [appConfigMock, authConfigMock],
}),
LoggerModule,
],
@ -100,7 +101,7 @@ describe('UsersService', () => {
return user;
},
);
await service.changeDisplayName(user, newDisplayName);
await service.updateUser(user, newDisplayName, undefined, undefined);
});
});

View file

@ -1,12 +1,14 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { REGEX_USERNAME } from '@hedgedoc/commons';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import AuthConfiguration, { AuthConfig } from '../config/auth.config';
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { Username } from '../utils/username';
@ -22,6 +24,8 @@ import { User } from './user.entity';
export class UsersService {
constructor(
private readonly logger: ConsoleLoggerService,
@Inject(AuthConfiguration.KEY)
private authConfig: AuthConfig,
@InjectRepository(User) private userRepository: Repository<User>,
) {
this.logger.setContext(UsersService.name);
@ -32,11 +36,24 @@ export class UsersService {
* Create a new user with a given username and displayName
* @param {Username} username - the username the new user shall have
* @param {string} displayName - the display name the new user shall have
* @param {string} [email] - the email the new user shall have
* @param {string} [photoUrl] - the photoUrl the new user shall have
* @return {User} the user
* @throws {BadRequestException} if the username contains invalid characters or is too short
* @throws {AlreadyInDBError} the username is already taken.
*/
async createUser(username: Username, displayName: string): Promise<User> {
const user = User.create(username, displayName);
async createUser(
username: Username,
displayName: string,
email?: string,
photoUrl?: string,
): Promise<User> {
if (!REGEX_USERNAME.test(username)) {
throw new BadRequestException(
`The username '${username}' is not a valid username.`,
);
}
const user = User.create(username, displayName, email, photoUrl);
try {
return await this.userRepository.save(user);
} catch {
@ -66,13 +83,51 @@ export class UsersService {
/**
* @async
* Change the displayName of the specified user
* @param {User} user - the user to be changed
* @param displayName - the new displayName
* Update the given User with the given information.
* Use {@code null} to clear the stored value (email or profilePicture).
* Use {@code undefined} to keep the stored value.
* @param {User} user - the User to update
* @param {string | undefined} displayName - the displayName to update the user with
* @param {string | null | undefined} email - the email to update the user with
* @param {string | null | undefined} profilePicture - the profilePicture to update the user with
*/
async changeDisplayName(user: User, displayName: string): Promise<void> {
user.displayName = displayName;
await this.userRepository.save(user);
async updateUser(
user: User,
displayName?: string,
email?: string | null,
profilePicture?: string | null,
): Promise<User> {
let shouldSave = false;
if (displayName !== undefined) {
user.displayName = displayName;
shouldSave = true;
}
if (email !== undefined) {
user.email = email;
shouldSave = true;
}
if (profilePicture !== undefined) {
user.photo = profilePicture;
shouldSave = true;
// ToDo: handle LDAP images (https://github.com/hedgedoc/hedgedoc/issues/5032)
}
if (shouldSave) {
return await this.userRepository.save(user);
}
return user;
}
/**
* @async
* Checks if the user with the specified username exists
* @param username - the username to check
* @return {boolean} true if the user exists, false otherwise
*/
async checkIfUserExists(username: Username): Promise<boolean> {
const user = await this.userRepository.findOne({
where: { username: username },
});
return user !== null;
}
/**

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -27,7 +27,8 @@ export function setupSessionMiddleware(
name: HEDGEDOC_SESSION,
secret: authConfig.session.secret,
cookie: {
maxAge: authConfig.session.lifetime,
// Handle session duration in seconds instead of ms
maxAge: authConfig.session.lifetime * 1000,
},
resave: false,
saveUninitialized: false,

View file

@ -41,6 +41,7 @@ describe('App', () => {
session: {
secret: 'secret',
},
oidc: [],
})
.compile();

View file

@ -278,7 +278,7 @@ describe('Auth', () => {
await request(testSetup.app.getHttpServer())
.delete('/api/private/auth/logout')
.set('Cookie', cookie)
.expect(204);
.expect(200);
});
});
});

View file

@ -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
*/
@ -8,7 +8,7 @@ import request from 'supertest';
import { HistoryEntryImportDto } from '../../src/history/history-entry-import.dto';
import { HistoryEntry } from '../../src/history/history-entry.entity';
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 { NotesService } from '../../src/notes/notes.service';
import { User } from '../../src/users/user.entity';
@ -18,7 +18,7 @@ import { TestSetup, TestSetupBuilder } from '../test-setup';
describe('History', () => {
let testSetup: TestSetup;
let historyService: HistoryService;
let identityService: IdentityService;
let localIdentityService: LocalService;
let user: User;
let note: Note;
let note2: Note;
@ -40,9 +40,9 @@ describe('History', () => {
content = 'This is a test note.';
historyService = moduleRef.get(HistoryService);
const userService = moduleRef.get(UsersService);
identityService = moduleRef.get(IdentityService);
localIdentityService = moduleRef.get(LocalService);
user = await userService.createUser(username, 'Testy');
await identityService.createLocalIdentity(user, password);
await localIdentityService.createLocalIdentity(user, password);
const notesService = moduleRef.get(NotesService);
note = await notesService.createNote(content, user, 'note');
note2 = await notesService.createNote(content, user, 'note2');

View file

@ -33,7 +33,7 @@ describe('Me', () => {
await testSetup.app.init();
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.';
alias2 = 'note2';

View file

@ -40,9 +40,9 @@ describe('Notes', () => {
const groupname1 = 'groupname1';
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');
await testSetup.identityService.createLocalIdentity(user2, password2);
await testSetup.localIdentityService.createLocalIdentity(user2, password2);
group1 = await testSetup.groupService.createGroup(groupname1, 'Group 1');

View file

@ -58,7 +58,7 @@ describe('Register and Login', () => {
expect(profile.body.authProvider).toEqual('local');
// 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
await session.get('/api/private/me').expect(401);

View file

@ -23,13 +23,13 @@ describe('Users', () => {
test('details for existing users can be retrieved', async () => {
let response = await request
.agent(testSetup.app.getHttpServer())
.get('/api/private/users/testuser1');
.get('/api/private/users/profile/testuser1');
expect(response.status).toBe(200);
expect(response.body.username).toBe('testuser1');
response = await request
.agent(testSetup.app.getHttpServer())
.get('/api/private/users/testuser2');
.get('/api/private/users/profile/testuser2');
expect(response.status).toBe(200);
expect(response.body.username).toBe('testuser2');
});
@ -37,7 +37,7 @@ describe('Users', () => {
test('details for non-existing users cannot be retrieved', async () => {
const response = await request
.agent(testSetup.app.getHttpServer())
.get('/api/private/users/i_dont_exist');
.get('/api/private/users/profile/i_dont_exist');
expect(response.status).toBe(404);
});
});

View file

@ -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
*/
@ -59,6 +59,9 @@ import { HistoryModule } from '../src/history/history.module';
import { HistoryService } from '../src/history/history.service';
import { IdentityModule } from '../src/identity/identity.module';
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 { LoggerModule } from '../src/logger/logger.module';
import { MediaModule } from '../src/media/media.module';
@ -101,6 +104,9 @@ export class TestSetup {
groupService: GroupsService;
configService: ConfigService;
identityService: IdentityService;
localIdentityService: LocalService;
ldapService: LdapService;
oidcService: OidcService;
notesService: NotesService;
mediaService: MediaService;
historyService: HistoryService;
@ -324,6 +330,8 @@ export class TestSetupBuilder {
this.testSetup.moduleRef.get<ConfigService>(ConfigService);
this.testSetup.identityService =
this.testSetup.moduleRef.get<IdentityService>(IdentityService);
this.testSetup.localIdentityService =
this.testSetup.moduleRef.get<LocalService>(LocalService);
this.testSetup.notesService =
this.testSetup.moduleRef.get<NotesService>(NotesService);
this.testSetup.mediaService =
@ -342,6 +350,10 @@ export class TestSetupBuilder {
this.testSetup.moduleRef.get<SessionService>(SessionService);
this.testSetup.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();
@ -389,15 +401,15 @@ export class TestSetupBuilder {
);
// Create identities for login
await this.testSetup.identityService.createLocalIdentity(
await this.testSetup.localIdentityService.createLocalIdentity(
this.testSetup.users[0],
password1,
);
await this.testSetup.identityService.createLocalIdentity(
await this.testSetup.localIdentityService.createLocalIdentity(
this.testSetup.users[1],
password2,
);
await this.testSetup.identityService.createLocalIdentity(
await this.testSetup.localIdentityService.createLocalIdentity(
this.testSetup.users[2],
password3,
);

View file

@ -12,4 +12,5 @@ export * from './parse-url/index.js'
export * from './permissions/index.js'
export * from './title-extraction/index.js'
export * from './y-doc-sync/index.js'
export * from './regex/index.js'
export * from './utils/index.js'

View 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'

View 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}$/

View file

@ -48,11 +48,7 @@ using one of the supported authentication methods:
- Username & Password (`local`)
- LDAP
- SAML
- OAuth2
- GitLab
- GitHub
- Google
- OIDC
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

View file

@ -16,7 +16,7 @@ which contains the following information:
HedgeDoc 2 supports multiple authentication methods per user.
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*.
This identity is used to automatically update profile attributes like the

View 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. |

View file

@ -1,22 +1,25 @@
# LDAP
HedgeDoc can use one or multiple LDAP servers to authenticate users. To do this,
you first need to tell HedgeDoc the names of servers you want to use (`HD_AUTH_LDAPS`),
and then you need to provide the configuration for those LDAP servers
HedgeDoc can use one or multiple LDAP servers to authenticate users. To do this, you
first need to tell HedgeDoc identifiers for the servers you want to use (`HD_AUTH_LDAP_SERVERS`).
Then you need to provide the configuration for these LDAP servers
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
will start with `HD_AUTH_LDAP_MY_LDAP`.
Each of these variables will contain the identifier for the LDAP server.
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 |
|--------------------------------------------|----------------------|----------------------------------------------------|---------------------------------------------------------------------------------------------------------------|
| `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_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_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_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_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. |

View file

@ -1,8 +1,8 @@
# Local
HedgeDoc provides local accounts, handled internally. This feature only provides basic
functionality, so for most environments we recommend using an external authentication mechanism,
which also enable more secure authentication like 2FA or WebAuthn.
functionality, so for most environments, we recommend using an external authentication mechanism,
which also enables more secure authentication like 2FA or Passkeys.
| 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) |
|:-----:|-------------------------------------------------------------------|---------------------------------------------------|
| 0 | All passwords are allowed | - |
| 0 | All passwords with minimum 6 characters are allowed | - |
| 1 | Only `too guessable` passwords are disallowed | 1.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 |

View 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/) |

View file

@ -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_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_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

View file

@ -4,9 +4,9 @@ HedgeDoc can be configured via environment variables either directly or via an `
## The `.env` file
The `.env` file should be in the working directory of the backend and contains key-value pairs of
environment variables and their corresponding value.
In the official Docker container this is `/usr/src/app/backend/`
The `.env` file should be in the root directory of the HedgeDoc application and
contains key-value pairs of environment variables and their corresponding value.
In the official Docker container this is `/usr/src/app/.env`
This can for example look like this:
<!-- markdownlint-disable proper-names -->

View file

@ -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_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_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. |

View file

@ -41,8 +41,10 @@ nav:
- Notes: references/config/notes.md
- Database: references/config/database.md
- Authentication:
- Overview: references/config/auth/index.md
- 'Local accounts': references/config/auth/local.md
- LDAP: references/config/auth/ldap.md
- 'OpenID Connect (OIDC)': references/config/auth/oidc.md
- Customization: references/config/customization.md
- Media Backends:
- Azure: references/config/media/azure.md

View file

@ -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
*/
@ -76,14 +76,17 @@ describe('When logged-out ', () => {
it('sign-in button points to auth-provider', () => {
initLoggedOutTestWithCustomAuthProviders(cy, [
{
type: AuthProviderType.GITHUB
type: AuthProviderType.OIDC,
identifier: 'github',
providerName: 'GitHub',
theme: 'github'
}
])
cy.getByCypressId('sign-in-button')
.should('be.visible')
.parent()
// 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', () => {
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')
@ -108,7 +117,10 @@ describe('When logged-out ', () => {
it('sign-in button points to login route', () => {
initLoggedOutTestWithCustomAuthProviders(cy, [
{
type: AuthProviderType.GITHUB
type: AuthProviderType.OIDC,
identifier: 'github',
providerName: 'GitHub',
theme: 'github'
},
{
type: AuthProviderType.LOCAL

View file

@ -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
*/
@ -21,12 +21,6 @@ export const branding = {
}
export const authProviders = [
{
type: AuthProviderType.GITHUB
},
{
type: AuthProviderType.GOOGLE
},
{
type: AuthProviderType.LOCAL
},
@ -36,24 +30,16 @@ export const authProviders = [
providerName: 'Test LDAP'
},
{
type: AuthProviderType.OAUTH2,
identifier: 'test-oauth2',
providerName: 'Test OAuth2'
},
{
type: AuthProviderType.SAML,
identifier: 'test-saml',
providerName: 'Test SAML'
},
{
type: AuthProviderType.GITLAB,
identifier: 'test-gitlab',
providerName: 'Test GitLab'
type: AuthProviderType.OIDC,
identifier: 'test-oidc',
providerName: 'Test OIDC'
}
]
export const config = {
allowRegister: true,
allowProfileEdits: true,
allowChooseUsername: true,
guestAccess: 'write',
authProviders: authProviders,
branding: branding,

View file

@ -169,6 +169,10 @@
"successTitle": "Password changed",
"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.",
"accountManagement": "Account management",
"deleteUser": "Delete user",
@ -608,6 +612,13 @@
"usernameExisting": "There is already an account with this username.",
"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": {

View file

@ -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
*/
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.
*
* @throws {Error} if logout is not possible.
*/
export const doLogout = async (): Promise<void> => {
await new DeleteApiRequestBuilder('auth/logout').sendRequest()
export const doLogout = async (): Promise<LogoutResponseDto> => {
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
}

View file

@ -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
*/
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.
@ -14,11 +14,16 @@ import type { LoginDto } from './types'
* @param password The password of the user.
* @throws {Error} when the api request wasn't successfull
*/
export const doLdapLogin = async (provider: string, username: string, password: string): Promise<void> => {
await new PostApiRequestBuilder<void, LoginDto>('auth/ldap/' + provider)
export const doLdapLogin = async (
provider: string,
username: string,
password: string
): Promise<LdapLoginResponseDto> => {
const response = await new PostApiRequestBuilder<LdapLoginResponseDto, LoginDto>(`auth/ldap/${provider}/login`)
.withJsonBody({
username: username,
password: password
})
.sendRequest()
return await response.asParsedJsonObject()
}

View 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()
}

View file

@ -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
*/
@ -19,3 +19,25 @@ export interface ChangePasswordDto {
currentPassword: 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
}

View file

@ -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
*/
export interface FrontendConfig {
allowRegister: boolean
allowProfileEdits: boolean
allowChooseUsername: boolean
authProviders: AuthProvider[]
branding: BrandingConfig
guestAccess: GuestAccessLevel
@ -24,38 +26,22 @@ export enum GuestAccessLevel {
}
export enum AuthProviderType {
GITHUB = 'github',
GOOGLE = 'google',
GITLAB = 'gitlab',
OAUTH2 = 'oauth2',
OIDC = 'oidc',
LDAP = 'ldap',
SAML = 'saml',
LOCAL = 'local'
}
export type AuthProviderTypeWithCustomName =
| AuthProviderType.GITLAB
| AuthProviderType.OAUTH2
| AuthProviderType.LDAP
| AuthProviderType.SAML
export type AuthProviderTypeWithCustomName = AuthProviderType.LDAP | AuthProviderType.OIDC
export type AuthProviderTypeWithoutCustomName =
| AuthProviderType.GITHUB
| AuthProviderType.GOOGLE
| AuthProviderType.LOCAL
export type AuthProviderTypeWithoutCustomName = AuthProviderType.LOCAL
export const authProviderTypeOneClick = [
AuthProviderType.GITHUB,
AuthProviderType.GITLAB,
AuthProviderType.GOOGLE,
AuthProviderType.OAUTH2,
AuthProviderType.SAML
]
export const authProviderTypeOneClick = [AuthProviderType.OIDC]
export interface AuthProviderWithCustomName {
type: AuthProviderTypeWithCustomName
identifier: string
providerName: string
theme?: string
}
export interface AuthProviderWithoutCustomName {

View file

@ -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
*/
@ -13,7 +13,7 @@ import type { UserInfo } from './types'
* @return Metadata about the requested user.
* @throws {Error} when the api request wasn't successful.
*/
export const getUser = async (username: string): Promise<UserInfo> => {
const response = await new GetApiRequestBuilder<UserInfo>('users/' + username).sendRequest()
export const getUserInfo = async (username: string): Promise<UserInfo> => {
const response = await new GetApiRequestBuilder<UserInfo>(`users/profile/${username}`).sendRequest()
return response.asParsedJsonObject()
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -7,5 +7,9 @@
export interface UserInfo {
username: string
displayName: string
photoUrl: string
photoUrl?: string
}
export interface FullUserInfo extends UserInfo {
email: string
}

View file

@ -7,20 +7,14 @@
*/
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 { 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 { LdapLoginCards } from '../../../components/login-page/ldap/ldap-login-cards'
import { OneClickLoginCard } from '../../../components/login-page/one-click/one-click-login-card'
import { GuestCard } from '../../../components/login-page/guest/guest-card'
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
import { LoginLayout } from '../../../components/layout/login-layout'
const LoginPage: NextPage = () => {
const userLoggedIn = useIsLoggedIn()
@ -30,30 +24,12 @@ const LoginPage: NextPage = () => {
}
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'}>
<GuestCard />
<LocalLoginCard />
<LdapLoginCards />
<OneClickLoginCard />
</Col>
</Row>
</Container>
<LoginLayout>
<GuestCard />
<LocalLoginCard />
<LdapLoginCards />
<OneClickLoginCard />
</LoginLayout>
)
}

View 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

View file

@ -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
*/
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import type { CommonFieldProps } from './fields'
import React, { useMemo } from 'react'
import React, { useEffect, useMemo } from 'react'
import { Form } from 'react-bootstrap'
import { Trans } from 'react-i18next'
import { useFrontendConfig } from '../frontend-config-context/use-frontend-config'
interface DisplayNameFieldProps extends CommonFieldProps {
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 value The currently entered display name.
* @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 placeholderText = useTranslatedText('profile.displayName')
const profileEditsAllowed = useFrontendConfig().allowProfileEdits
useEffect(() => {
onValidityChange?.(isValid)
}, [isValid, onValidityChange])
return (
<Form.Group>
@ -37,6 +50,7 @@ export const DisplayNameField: React.FC<DisplayNameFieldProps> = ({ onChange, va
onChange={onChange}
placeholder={placeholderText}
autoComplete='name'
disabled={!profileEditsAllowed}
required
/>
<Form.Text>

View file

@ -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
*/
import type { ChangeEvent } from 'react'
import type { ChangeEventHandler } from 'react'
export interface CommonFieldProps {
onChange: (event: ChangeEvent<HTMLInputElement>) => void
value: string
export interface CommonFieldProps<ValueType = undefined> {
onChange: ValueType extends undefined ? ChangeEventHandler : (set: ValueType) => void
value: ValueType extends undefined ? string : ValueType
hasError?: boolean
disabled?: boolean
}

View file

@ -16,7 +16,7 @@ import { Trans } from 'react-i18next'
* @param value The currently entered password.
*/
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')

View file

@ -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>
)
}

View file

@ -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
*/
@ -11,6 +11,7 @@ import { Form } from 'react-bootstrap'
export interface UsernameFieldProps extends CommonFieldProps {
isInvalid?: 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 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')
return (
@ -29,10 +30,12 @@ export const UsernameField: React.FC<UsernameFieldProps> = ({ onChange, value, i
type='text'
size='sm'
value={value}
maxLength={64}
isValid={isValid}
isInvalid={isInvalid}
onChange={onChange}
placeholder={placeholderText}
disabled={disabled}
autoComplete='username'
autoFocus={true}
required

View file

@ -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
*/
import type { UsernameFieldProps } from './username-field'
import { UsernameField } from './username-field'
import React from 'react'
import React, { useEffect, useState } from 'react'
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
*
* @param onChange Callback that is called when the entered username changes.
* @param value The currently entered username.
* @param isValid Is a valid field or not
* @param isInvalid Adds error style to label
* @param onValidityChange Callback that is called when the validity of the field changes.
* @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 (
<Form.Group>
<Form.Label>
<Trans i18nKey='login.auth.username' />
</Form.Label>
<UsernameField {...props} />
<UsernameField
value={value}
{...props}
disabled={!usernameChoosingAllowed}
isInvalid={usernameInvalid}
isValid={usernameValid}
/>
<Form.Text>
<Trans i18nKey='login.register.usernameInfo' />
</Form.Text>

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -12,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.
*
* @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.
*/
export const useAvatarUrl = (photoUrl: string | undefined, displayName: string): string => {
export const useAvatarUrl = (photoUrl: string | undefined, username: string): string => {
return useMemo(() => {
if (photoUrl && photoUrl.trim() !== '') {
return photoUrl
}
const avatar = createAvatar(identicon, {
seed: displayName
seed: username
})
return avatar.toDataUri()
}, [photoUrl, displayName])
}, [photoUrl, username])
}

View file

@ -19,5 +19,5 @@ export interface UserAvatarForUserProps extends Omit<UserAvatarProps, 'photoUrl'
* @param props remaining avatar 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} />
}

View file

@ -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
*/
import { getUser } from '../../../api/users'
import { getUserInfo } from '../../../api/users'
import { AsyncLoadingBoundary } from '../async-loading-boundary/async-loading-boundary'
import type { UserAvatarProps } 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 { useAsync } from 'react-use'
@ -28,15 +28,18 @@ export const UserAvatarForUsername: React.FC<UserAvatarForUsernameProps> = ({ us
const { t } = useTranslation()
const { error, value, loading } = useAsync(async (): Promise<{ displayName: string; photo?: string }> => {
return username
? await getUser(username)
? await getUserInfo(username)
: {
displayName: t('common.guestUser')
}
}, [username, t])
const avatar = useMemo(() => {
return !value ? <Fragment /> : <UserAvatar displayName={value.displayName} photoUrl={value.photo} {...props} />
}, [props, value])
if (!value) {
return null
}
return <UserAvatar displayName={value.displayName} photoUrl={value.photo} username={username} {...props} />
}, [props, value, username])
return (
<AsyncLoadingBoundary loading={loading || !value} error={error} componentName={'UserAvatarForUsername'}>

View file

@ -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
*/
@ -14,6 +14,7 @@ export interface UserAvatarProps {
showName?: boolean
photoUrl?: string
displayName: string
username?: string | null
}
/**
@ -29,7 +30,8 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
displayName,
size,
additionalClasses = '',
showName = true
showName = true,
username
}) => {
const imageSize = useMemo(() => {
switch (size) {
@ -42,7 +44,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
}
}, [size])
const avatarUrl = useAvatarUrl(photoUrl, displayName)
const avatarUrl = useAvatarUrl(photoUrl, username ?? displayName)
const imageTranslateOptions = useMemo(
() => ({

View file

@ -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
*/
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 { setNotePermissionsFromServer } from '../../../../../../redux/note-details/methods'
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])
const { value, loading, error } = useAsync(async () => {
return await getUser(entry.username)
return await getUserInfo(entry.username)
}, [entry.username])
if (!value) {

View file

@ -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
*/
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 { download } from '../../../../../common/download/download'
@ -34,7 +34,7 @@ export const getUserDataForRevision = async (usernames: string[]): Promise<UserI
const users: UserInfo[] = []
const usersToFetch = Math.min(usernames.length, DISPLAY_MAX_USERS_PER_REVISION) - 1
for (let i = 0; i <= usersToFetch; i++) {
const user = await getUser(usernames[i])
const user = await getUserInfo(usernames[i])
users.push(user)
}
return users

View file

@ -25,7 +25,7 @@ export const SignOutDropdownButton: React.FC = () => {
const onSignOut = useCallback(() => {
clearUser()
doLogout()
.then(() => router.push('/login'))
.then((logoutResponse) => router.push(logoutResponse.redirect))
.catch(showErrorNotification('login.logoutFailed'))
}, [showErrorNotification, router])

View 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>
)
}

View file

@ -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
*/
@ -13,6 +13,7 @@ import { Alert, Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { fetchAndSetUser } from '../utils/fetch-and-set-user'
import { PasswordField } from '../password-field'
import { useRouter } from 'next/navigation'
export interface ViaLdapProps {
providerName: string
@ -25,18 +26,32 @@ export interface ViaLdapProps {
export const LdapLoginCard: React.FC<ViaLdapProps> = ({ providerName, identifier }) => {
useTranslation()
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string>()
const onLoginSubmit = useCallback(
(event: FormEvent) => {
let redirect = false
doLdapLogin(identifier, username, password)
.then(() => fetchAndSetUser())
.catch((error: Error) => setError(error.message))
.then((response) => {
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()
},
[username, password, identifier]
[username, password, identifier, router]
)
const onUsernameChange = useLowercaseOnInputChange(setUsername)
@ -49,10 +64,10 @@ export const LdapLoginCard: React.FC<ViaLdapProps> = ({ providerName, identifier
<Trans i18nKey='login.signInVia' values={{ service: providerName }} />
</Card.Title>
<Form onSubmit={onLoginSubmit} className={'d-flex gap-3 flex-column'}>
<UsernameField onChange={onUsernameChange} isValid={!!error} value={username} />
<PasswordField onChange={onPasswordChange} invalid={!!error} />
<UsernameField onChange={onUsernameChange} isInvalid={!!error} value={username} />
<PasswordField onChange={onPasswordChange} isInvalid={!!error} />
<Alert className='small' show={!!error} variant='danger'>
<Trans i18nKey={error} />
{error}
</Alert>
<Button type='submit' variant='primary'>

View file

@ -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
*/
@ -53,7 +53,7 @@ export const LocalLoginCardBody: React.FC = () => {
</Card.Title>
<Form onSubmit={onLoginSubmit} className={'d-flex gap-3 flex-column'}>
<UsernameField onChange={onUsernameChange} isInvalid={!!error} value={username} />
<PasswordField onChange={onPasswordChange} invalid={!!error} />
<PasswordField onChange={onPasswordChange} isInvalid={!!error} />
<Alert className='small' show={!!error} variant='danger'>
<Trans i18nKey={error} />
</Alert>

View file

@ -1,9 +1,3 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
'use client'
/*
* 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 { NewPasswordField } from '../../../common/fields/new-password-field'
import { PasswordAgainField } from '../../../common/fields/password-again-field'
import { RegisterInfos } from '../../../register-page/register-infos'
import { RegisterError } from '../../../register-page/register-error'
import { RegisterInfos } from './register-infos'
import { RegisterError } from './register-error'
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.
@ -43,29 +38,34 @@ export const LocalRegisterForm: NextPage = () => {
const [error, setError] = useState<ApiError>()
const { dispatchUiNotification } = useUiNotifications()
const postLoginRedirectUrl = useGetPostLoginRedirectUrl()
const doRegisterSubmit = useCallback(
(event: FormEvent) => {
doLocalRegister(username, displayName, password)
.then(() => fetchAndSetUser())
.then(() => dispatchUiNotification('login.register.success.title', 'login.register.success.message', {}))
.then(() => router.push('/history'))
.then(() => router.push(postLoginRedirectUrl))
.catch((error: ApiError) => setError(error))
event.preventDefault()
},
[username, displayName, password, dispatchUiNotification, router]
[username, displayName, password, dispatchUiNotification, router, postLoginRedirectUrl]
)
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])
const isWeakPassword = useMemo(() => {
return error?.backendErrorName === 'PasswordTooWeakError'
}, [error])
const isValidUsername = useMemo(() => Boolean(username.trim()), [username])
const onUsernameChange = useLowercaseOnInputChange(setUsername)
const onDisplayNameChange = useOnInputChange(setDisplayName)
const onPasswordChange = useOnInputChange(setPassword)
@ -73,7 +73,7 @@ export const LocalRegisterForm: NextPage = () => {
return (
<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} />
<NewPasswordField onChange={onPasswordChange} value={password} hasError={isWeakPassword} />
<PasswordAgainField

View file

@ -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
*/
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 { Alert } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'

View file

@ -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
*/
import { useFrontendConfig } from '../common/frontend-config-context/use-frontend-config'
import { TranslatedExternalLink } from '../common/links/translated-external-link'
import { useFrontendConfig } from '../../../common/frontend-config-context/use-frontend-config'
import { TranslatedExternalLink } from '../../../common/links/translated-external-link'
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'

View 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>
)
}

View file

@ -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
*/
import styles from './via-one-click.module.scss'
import type { Icon } from 'react-bootstrap-icons'
import {
Exclamation as IconExclamation,
Github as IconGithub,
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'
import { Logger } from '../../../utils/logger'
import type { AuthProvider } from '../../../api/config/types'
import { AuthProviderType } from '../../../api/config/types'
import { IconGitlab } from '../../common/icons/additional/icon-gitlab'
import styles from './one-click-login-button.module.scss'
export interface OneClickMetadata {
name: string
@ -24,10 +28,6 @@ export interface OneClickMetadata {
url: string
}
const getBackendAuthUrl = (providerIdentifer: string): string => {
return `/auth/${providerIdentifer}`
}
const logger = new Logger('GetOneClickProviderMetadata')
/**
@ -37,49 +37,57 @@ const logger = new Logger('GetOneClickProviderMetadata')
* @return Name, icon, URL and CSS class of the given provider for rendering a login button.
*/
export const getOneClickProviderMetadata = (provider: AuthProvider): OneClickMetadata => {
switch (provider.type) {
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)
return {
name: '',
icon: IconExclamation,
className: '',
url: '#'
}
if (provider.type !== AuthProviderType.OIDC) {
logger.warn('Metadata for one-click-provider does not exist', provider)
return {
name: '',
icon: IconExclamation,
className: '',
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}`
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -10,7 +10,7 @@ import { useTranslatedText } from '../../hooks/common/use-translated-text'
export interface AuthFieldProps {
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 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')
return (
<Form.Group>
<Form.Control
isInvalid={invalid}
isInvalid={isInvalid}
type='password'
size='sm'
placeholder={placeholderText}

Some files were not shown because too many files have changed in this diff Show more