diff --git a/backend/package.json b/backend/package.json index 6f3af2cbf..2b51b0d56 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/api/private/auth/auth.controller.ts b/backend/src/api/private/auth/auth.controller.ts index 34c41d8f0..3277527a0 100644 --- a/backend/src/api/private/auth/auth.controller.ts +++ b/backend/src/api/private/auth/auth.controller.ts @@ -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 { - 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 { - 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 { - 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 { + 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 { + 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; } } diff --git a/backend/src/api/private/auth/ldap/ldap.controller.ts b/backend/src/api/private/auth/ldap/ldap.controller.ts new file mode 100644 index 000000000..3100c90fa --- /dev/null +++ b/backend/src/api/private/auth/ldap/ldap.controller.ts @@ -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'); + } + } +} diff --git a/backend/src/api/private/auth/local/local.controller.ts b/backend/src/api/private/auth/local/local.controller.ts new file mode 100644 index 000000000..324125306 --- /dev/null +++ b/backend/src/api/private/auth/local/local.controller.ts @@ -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 { + 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 { + 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 { + 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'); + } + } +} diff --git a/backend/src/api/private/auth/oidc/oidc.controller.ts b/backend/src/api/private/auth/oidc/oidc.controller.ts new file mode 100644 index 000000000..af92314eb --- /dev/null +++ b/backend/src/api/private/auth/oidc/oidc.controller.ts @@ -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(); + } + } +} diff --git a/backend/src/api/private/me/me.controller.ts b/backend/src/api/private/me/me.controller.ts index aa2120519..6d27a9c46 100644 --- a/backend/src/api/private/me/me.controller.ts +++ b/backend/src/api/private/me/me.controller.ts @@ -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 { - await this.userService.changeDisplayName(user, newDisplayName); + await this.userService.updateUser( + user, + newDisplayName, + undefined, + undefined, + ); } } diff --git a/backend/src/api/private/private-api.module.ts b/backend/src/api/private/private-api.module.ts index 9b0fc97d7..fd31242a9 100644 --- a/backend/src/api/private/private-api.module.ts +++ b/backend/src/api/private/private-api.module.ts @@ -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 {} diff --git a/backend/src/api/private/users/users.controller.ts b/backend/src/api/private/users/users.controller.ts index 32133fc26..20ce960b9 100644 --- a/backend/src/api/private/users/users.controller.ts +++ b/backend/src/api/private/users/users.controller.ts @@ -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 { + 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 { return this.userService.toUserDto( diff --git a/backend/src/api/utils/session-authprovider.decorator.ts b/backend/src/api/utils/session-authprovider.decorator.ts index dc9e65d34..e3222ad37 100644 --- a/backend/src/api/utils/session-authprovider.decorator.ts +++ b/backend/src/api/utils/session-authprovider.decorator.ts @@ -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; }, ); diff --git a/backend/src/config/auth.config.spec.ts b/backend/src/config/auth.config.spec.ts index 4654dfb38..5d9b93ca9 100644 --- a/backend/src/config/auth.config.spec.ts +++ b/backend/src/config/auth.config.spec.ts @@ -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(); + }); + }); + }); }); diff --git a/backend/src/config/auth.config.ts b/backend/src/config/auth.config.ts index 38b21e2a5..68c218306 100644 --- a/backend/src/config/auth.config.ts +++ b/backend/src/config/auth.config.ts @@ -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)); diff --git a/backend/src/config/mock/auth.config.mock.ts b/backend/src/config/mock/auth.config.mock.ts index 208206241..c46420bca 100644 --- a/backend/src/config/mock/auth.config.mock.ts +++ b/backend/src/config/mock/auth.config.mock.ts @@ -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: [], }; } diff --git a/backend/src/config/theme.enum.ts b/backend/src/config/theme.enum.ts new file mode 100644 index 000000000..407a0bf07 --- /dev/null +++ b/backend/src/config/theme.enum.ts @@ -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', +} diff --git a/backend/src/config/utils.spec.ts b/backend/src/config/utils.spec.ts index 62a57b030..2fdd695f5 100644 --- a/backend/src/config/utils.spec.ts +++ b/backend/src/config/utils.spec.ts @@ -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( diff --git a/backend/src/config/utils.ts b/backend/src/config/utils.ts index 64d897094..3ba44d93b 100644 --- a/backend/src/config/utils.ts +++ b/backend/src/config/utils.ts @@ -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; } diff --git a/backend/src/frontend-config/frontend-config.dto.ts b/backend/src/frontend-config/frontend-config.dto.ts index 9f82b6779..05c2be0cc 100644 --- a/backend/src/frontend-config/frontend-config.dto.ts +++ b/backend/src/frontend-config/frontend-config.dto.ts @@ -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? */ diff --git a/backend/src/frontend-config/frontend-config.service.spec.ts b/backend/src/frontend-config/frontend-config.service.spec.ts index c2f5d4c33..a491082b9 100644 --- a/backend/src/frontend-config/frontend-config.service.spec.ts +++ b/backend/src/frontend-config/frontend-config.service.spec.ts @@ -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, }); } }); diff --git a/backend/src/frontend-config/frontend-config.service.ts b/backend/src/frontend-config/frontend-config.service.ts index 599a51dbb..b880c1352 100644 --- a/backend/src/frontend-config/frontend-config.service.ts +++ b/backend/src/frontend-config/frontend-config.service.ts @@ -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; diff --git a/backend/src/identity/identity.entity.ts b/backend/src/identity/identity.entity.ts index d6826bc93..3564aa46e 100644 --- a/backend/src/identity/identity.entity.ts +++ b/backend/src/identity/identity.entity.ts @@ -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 { 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; } diff --git a/backend/src/identity/identity.module.ts b/backend/src/identity/identity.module.ts index 57794f4ce..728a47a98 100644 --- a/backend/src/identity/identity.module.ts +++ b/backend/src/identity/identity.module.ts @@ -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 {} diff --git a/backend/src/identity/identity.service.spec.ts b/backend/src/identity/identity.service.spec.ts deleted file mode 100644 index 7dfe4427e..000000000 --- a/backend/src/identity/identity.service.spec.ts +++ /dev/null @@ -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; - 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); - user = User.create('test', 'Testy') as User; - identityRepo = module.get>( - getRepositoryToken(Identity), - ); - }); - - describe('createLocalIdentity', () => { - it('works', async () => { - jest - .spyOn(identityRepo, 'save') - .mockImplementationOnce( - async (identity: Identity): Promise => 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, - ) - .mockImplementationOnce( - async (identity: Identity): Promise => 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); - }); - }); - }); -}); diff --git a/backend/src/identity/identity.service.ts b/backend/src/identity/identity.service.ts index a695324c7..855103cf0 100644 --- a/backend/src/identity/identity.service.ts +++ b/backend/src/identity/identity.service.ts @@ -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, - @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 { 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 { - 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 { - 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 { - 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 { - 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 { - 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; + 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 { - 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(); } } } diff --git a/backend/src/identity/ldap/ldap.service.ts b/backend/src/identity/ldap/ldap.service.ts new file mode 100644 index 000000000..dba7b02a7 --- /dev/null +++ b/backend/src/identity/ldap/ldap.service.ts @@ -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 = { + /* 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 { + return new Promise((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) => { + 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); + } +} diff --git a/backend/src/identity/ldap/ldap.strategy.ts b/backend/src/identity/ldap/ldap.strategy.ts deleted file mode 100644 index e8bc69401..000000000 --- a/backend/src/identity/ldap/ldap.strategy.ts +++ /dev/null @@ -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 = { - /* 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, - 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) => { - 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, - 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} user - the user object from ldap - * @private - */ - private async updateIdentity( - identity: Identity, - displayNameField: string, - profilePictureField: string, - user: Record, - ): Promise { - 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); - } -} diff --git a/backend/src/identity/local/local.service.ts b/backend/src/identity/local/local.service.ts new file mode 100644 index 000000000..a2bd482b0 --- /dev/null +++ b/backend/src/identity/local/local.service.ts @@ -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, + @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 { + 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 { + 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 { + 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 { + if (password.length < 6) { + throw new PasswordTooWeakError(); + } + const result = await zxcvbnAsync(password); + if (result.score < this.authConfig.local.minimalPasswordStrength) { + throw new PasswordTooWeakError(); + } + } +} diff --git a/backend/src/identity/local/local.strategy.ts b/backend/src/identity/local/local.strategy.ts deleted file mode 100644 index a70850ca6..000000000 --- a/backend/src/identity/local/local.strategy.ts +++ /dev/null @@ -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 { - 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; - } - } -} diff --git a/backend/src/identity/oidc/oidc.service.ts b/backend/src/identity/oidc/oidc.service.ts new file mode 100644 index 000000000..0a1210518 --- /dev/null +++ b/backend/src/identity/oidc/oidc.service.ts @@ -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 = 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 { + 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 { + 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; + 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 { + 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}` : ''}`; + } +} diff --git a/backend/src/identity/pending-user-confirmation.dto.ts b/backend/src/identity/pending-user-confirmation.dto.ts new file mode 100644 index 000000000..d4a3f82da --- /dev/null +++ b/backend/src/identity/pending-user-confirmation.dto.ts @@ -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; +} diff --git a/backend/src/identity/provider-type.enum.ts b/backend/src/identity/provider-type.enum.ts index 48b940b08..334b9ff67 100644 --- a/backend/src/identity/provider-type.enum.ts +++ b/backend/src/identity/provider-type.enum.ts @@ -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', } diff --git a/backend/src/identity/session.guard.ts b/backend/src/identity/session.guard.ts index 91902b822..402036952 100644 --- a/backend/src/identity/session.guard.ts +++ b/backend/src/identity/session.guard.ts @@ -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.'); diff --git a/backend/src/identity/utils.ts b/backend/src/identity/utils.ts deleted file mode 100644 index 47dfe10d9..000000000 --- a/backend/src/identity/utils.ts +++ /dev/null @@ -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 { - const identities = await user.identities; - if (identities === undefined) { - return undefined; - } - return identities.find( - (aIdentity) => aIdentity.providerType === (providerType as string), - ); -} diff --git a/backend/src/migrations/mariadb-1725204784823-init.ts b/backend/src/migrations/mariadb-1725266569705-init.ts similarity index 96% rename from backend/src/migrations/mariadb-1725204784823-init.ts rename to backend/src/migrations/mariadb-1725266569705-init.ts index df1fcd636..55835273b 100644 --- a/backend/src/migrations/mariadb-1725204784823-init.ts +++ b/backend/src/migrations/mariadb-1725266569705-init.ts @@ -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 { 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`, diff --git a/backend/src/migrations/postgres-1725203299761-init.ts b/backend/src/migrations/postgres-1725266697932-init.ts similarity index 96% rename from backend/src/migrations/postgres-1725203299761-init.ts rename to backend/src/migrations/postgres-1725266697932-init.ts index 6f278eaed..c3e14897f 100644 --- a/backend/src/migrations/postgres-1725203299761-init.ts +++ b/backend/src/migrations/postgres-1725266697932-init.ts @@ -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 { 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"))`, diff --git a/backend/src/migrations/sqlite-1725204990810-init.ts b/backend/src/migrations/sqlite-1725268109950-init.ts similarity index 95% rename from backend/src/migrations/sqlite-1725204990810-init.ts rename to backend/src/migrations/sqlite-1725268109950-init.ts index 9631b32de..da94e02e4 100644 --- a/backend/src/migrations/sqlite-1725204990810-init.ts +++ b/backend/src/migrations/sqlite-1725268109950-init.ts @@ -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 { 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( diff --git a/backend/src/public-auth-token/public-auth-token.service.spec.ts b/backend/src/public-auth-token/public-auth-token.service.spec.ts index 6dd735137..ae7de73ba 100644 --- a/backend/src/public-auth-token/public-auth-token.service.spec.ts +++ b/backend/src/public-auth-token/public-auth-token.service.spec.ts @@ -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, diff --git a/backend/src/seed.ts b/backend/src/seed.ts index e6b21b0a3..1c9b46dea 100644 --- a/backend/src/seed.ts +++ b/backend/src/seed.ts @@ -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); diff --git a/backend/src/sessions/session.service.ts b/backend/src/sessions/session.service.ts index 13fc08cb3..71c683a23 100644 --- a/backend/src/sessions/session.service.ts +++ b/backend/src/sessions/session.service.ts @@ -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; } /** diff --git a/backend/src/users/user-info.dto.ts b/backend/src/users/user-info.dto.ts index 02483498a..7246c69ce 100644 --- a/backend/src/users/user-info.dto.ts +++ b/backend/src/users/user-info.dto.ts @@ -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 { diff --git a/backend/src/users/user.entity.ts b/backend/src/users/user.entity.ts index 25567b547..abb09033e 100644 --- a/backend/src/users/user.entity.ts +++ b/backend/src/users/user.entity.ts @@ -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 { 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([]); diff --git a/backend/src/users/username-check.dto.ts b/backend/src/users/username-check.dto.ts new file mode 100644 index 000000000..8709aea0b --- /dev/null +++ b/backend/src/users/username-check.dto.ts @@ -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; +} diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 005d92bfd..ecd807b25 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -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); }); }); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 8854d3c3c..950af5fc0 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -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, ) { 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 { - const user = User.create(username, displayName); + async createUser( + username: Username, + displayName: string, + email?: string, + photoUrl?: string, + ): Promise { + 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 { - user.displayName = displayName; - await this.userRepository.save(user); + async updateUser( + user: User, + displayName?: string, + email?: string | null, + profilePicture?: string | null, + ): Promise { + 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 { + const user = await this.userRepository.findOne({ + where: { username: username }, + }); + return user !== null; } /** diff --git a/backend/src/utils/session.ts b/backend/src/utils/session.ts index f5f14b0bd..676a786af 100644 --- a/backend/src/utils/session.ts +++ b/backend/src/utils/session.ts @@ -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, diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index ad490a42d..cb429387d 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -41,6 +41,7 @@ describe('App', () => { session: { secret: 'secret', }, + oidc: [], }) .compile(); diff --git a/backend/test/private-api/auth.e2e-spec.ts b/backend/test/private-api/auth.e2e-spec.ts index d77b8f6d4..804b08a69 100644 --- a/backend/test/private-api/auth.e2e-spec.ts +++ b/backend/test/private-api/auth.e2e-spec.ts @@ -278,7 +278,7 @@ describe('Auth', () => { await request(testSetup.app.getHttpServer()) .delete('/api/private/auth/logout') .set('Cookie', cookie) - .expect(204); + .expect(200); }); }); }); diff --git a/backend/test/private-api/history.e2e-spec.ts b/backend/test/private-api/history.e2e-spec.ts index c4e5021ea..27bf460db 100644 --- a/backend/test/private-api/history.e2e-spec.ts +++ b/backend/test/private-api/history.e2e-spec.ts @@ -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'); diff --git a/backend/test/private-api/me.e2e-spec.ts b/backend/test/private-api/me.e2e-spec.ts index 9c6890551..793e87530 100644 --- a/backend/test/private-api/me.e2e-spec.ts +++ b/backend/test/private-api/me.e2e-spec.ts @@ -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'; diff --git a/backend/test/private-api/notes.e2e-spec.ts b/backend/test/private-api/notes.e2e-spec.ts index 9d8e5dce8..c7c372fd4 100644 --- a/backend/test/private-api/notes.e2e-spec.ts +++ b/backend/test/private-api/notes.e2e-spec.ts @@ -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'); diff --git a/backend/test/private-api/register-and-login.e2e-spec.ts b/backend/test/private-api/register-and-login.e2e-spec.ts index 132fac5c4..28428ebb2 100644 --- a/backend/test/private-api/register-and-login.e2e-spec.ts +++ b/backend/test/private-api/register-and-login.e2e-spec.ts @@ -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); diff --git a/backend/test/private-api/users.e2e-spec.ts b/backend/test/private-api/users.e2e-spec.ts index 99f18b663..07c078bee 100644 --- a/backend/test/private-api/users.e2e-spec.ts +++ b/backend/test/private-api/users.e2e-spec.ts @@ -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); }); }); diff --git a/backend/test/test-setup.ts b/backend/test/test-setup.ts index d97e9c183..fda3ed388 100644 --- a/backend/test/test-setup.ts +++ b/backend/test/test-setup.ts @@ -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); this.testSetup.identityService = this.testSetup.moduleRef.get(IdentityService); + this.testSetup.localIdentityService = + this.testSetup.moduleRef.get(LocalService); this.testSetup.notesService = this.testSetup.moduleRef.get(NotesService); this.testSetup.mediaService = @@ -342,6 +350,10 @@ export class TestSetupBuilder { this.testSetup.moduleRef.get(SessionService); this.testSetup.revisionsService = this.testSetup.moduleRef.get(RevisionsService); + this.testSetup.ldapService = + this.testSetup.moduleRef.get(LdapService); + this.testSetup.oidcService = + this.testSetup.moduleRef.get(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, ); diff --git a/commons/src/index.ts b/commons/src/index.ts index 49cbb055a..58223ea42 100644 --- a/commons/src/index.ts +++ b/commons/src/index.ts @@ -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' diff --git a/commons/src/regex/index.ts b/commons/src/regex/index.ts new file mode 100644 index 000000000..748cbfc9c --- /dev/null +++ b/commons/src/regex/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export * from './username.js' diff --git a/commons/src/regex/username.ts b/commons/src/regex/username.ts new file mode 100644 index 000000000..5d0992500 --- /dev/null +++ b/commons/src/regex/username.ts @@ -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}$/ diff --git a/docs/content/concepts/api-auth.md b/docs/content/concepts/api-auth.md index ad2c258d5..a05545fe4 100644 --- a/docs/content/concepts/api-auth.md +++ b/docs/content/concepts/api-auth.md @@ -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 diff --git a/docs/content/concepts/user-profiles.md b/docs/content/concepts/user-profiles.md index 341da9fcf..46c6b6210 100644 --- a/docs/content/concepts/user-profiles.md +++ b/docs/content/concepts/user-profiles.md @@ -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 diff --git a/docs/content/references/config/auth/index.md b/docs/content/references/config/auth/index.md new file mode 100644 index 000000000..6e594b60e --- /dev/null +++ b/docs/content/references/config/auth/index.md @@ -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. | diff --git a/docs/content/references/config/auth/ldap.md b/docs/content/references/config/auth/ldap.md index 9b989deac..20e699cd9 100644 --- a/docs/content/references/config/auth/ldap.md +++ b/docs/content/references/config/auth/ldap.md @@ -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. | diff --git a/docs/content/references/config/auth/local.md b/docs/content/references/config/auth/local.md index 277ca3d2f..28d623b0d 100644 --- a/docs/content/references/config/auth/local.md +++ b/docs/content/references/config/auth/local.md @@ -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 | diff --git a/docs/content/references/config/auth/oidc.md b/docs/content/references/config/auth/oidc.md new file mode 100644 index 000000000..524304c28 --- /dev/null +++ b/docs/content/references/config/auth/oidc.md @@ -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/) | diff --git a/docs/content/references/config/general.md b/docs/content/references/config/general.md index 91230101a..12dec8e02 100644 --- a/docs/content/references/config/general.md +++ b/docs/content/references/config/general.md @@ -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 diff --git a/docs/content/references/config/index.md b/docs/content/references/config/index.md index 6f7f8d1ff..ef4362b4c 100644 --- a/docs/content/references/config/index.md +++ b/docs/content/references/config/index.md @@ -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: diff --git a/docs/content/references/config/notes.md b/docs/content/references/config/notes.md index 439d9b406..63d0cd043 100644 --- a/docs/content/references/config/notes.md +++ b/docs/content/references/config/notes.md @@ -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. | diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 7fd2cf1c4..1889801b9 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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 diff --git a/frontend/cypress/e2e/signInButton.spec.ts b/frontend/cypress/e2e/signInButton.spec.ts index bc1dde71c..1ac80cf35 100644 --- a/frontend/cypress/e2e/signInButton.spec.ts +++ b/frontend/cypress/e2e/signInButton.spec.ts @@ -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 diff --git a/frontend/cypress/support/config.ts b/frontend/cypress/support/config.ts index 7522103f4..714321f0a 100644 --- a/frontend/cypress/support/config.ts +++ b/frontend/cypress/support/config.ts @@ -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, diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 44bc1e5e3..b77d8a8ad 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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": { diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts index b8a4f74ae..ba1946612 100644 --- a/frontend/src/api/auth/index.ts +++ b/frontend/src/api/auth/index.ts @@ -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 => { - await new DeleteApiRequestBuilder('auth/logout').sendRequest() +export const doLogout = async (): Promise => { + const response = await new DeleteApiRequestBuilder('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 => { + const response = await new PostApiRequestBuilder('users/check') + .withJsonBody({ username }) + .sendRequest() + const json = await response.asParsedJsonObject() + return json.usernameAvailable } diff --git a/frontend/src/api/auth/ldap.ts b/frontend/src/api/auth/ldap.ts index 65cdf5b37..f665d86ed 100644 --- a/frontend/src/api/auth/ldap.ts +++ b/frontend/src/api/auth/ldap.ts @@ -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 => { - await new PostApiRequestBuilder('auth/ldap/' + provider) +export const doLdapLogin = async ( + provider: string, + username: string, + password: string +): Promise => { + const response = await new PostApiRequestBuilder(`auth/ldap/${provider}/login`) .withJsonBody({ username: username, password: password }) .sendRequest() + return await response.asParsedJsonObject() } diff --git a/frontend/src/api/auth/pending-user.ts b/frontend/src/api/auth/pending-user.ts new file mode 100644 index 000000000..d72e36dae --- /dev/null +++ b/frontend/src/api/auth/pending-user.ts @@ -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> => { + const response = await new GetApiRequestBuilder>('auth/pending-user').sendRequest() + return response.asParsedJsonObject() +} + +/** + * Cancels the pending user. + */ +export const cancelPendingUser = async (): Promise => { + await new DeleteApiRequestBuilder('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 => { + await new PutApiRequestBuilder('auth/pending-user') + .withJsonBody(updatedUserInfo) + .sendRequest() +} diff --git a/frontend/src/api/auth/types.ts b/frontend/src/api/auth/types.ts index ca0820cbc..614d500b7 100644 --- a/frontend/src/api/auth/types.ts +++ b/frontend/src/api/auth/types.ts @@ -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 +} diff --git a/frontend/src/api/config/types.ts b/frontend/src/api/config/types.ts index 075a39861..8dc2b5d84 100644 --- a/frontend/src/api/config/types.ts +++ b/frontend/src/api/config/types.ts @@ -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 { diff --git a/frontend/src/api/users/index.ts b/frontend/src/api/users/index.ts index d785d2f73..d12f5da23 100644 --- a/frontend/src/api/users/index.ts +++ b/frontend/src/api/users/index.ts @@ -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 => { - const response = await new GetApiRequestBuilder('users/' + username).sendRequest() +export const getUserInfo = async (username: string): Promise => { + const response = await new GetApiRequestBuilder(`users/profile/${username}`).sendRequest() return response.asParsedJsonObject() } diff --git a/frontend/src/api/users/types.ts b/frontend/src/api/users/types.ts index f4ad524e6..88e887a9e 100644 --- a/frontend/src/api/users/types.ts +++ b/frontend/src/api/users/types.ts @@ -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 } diff --git a/frontend/src/app/(editor)/login/page.tsx b/frontend/src/app/(editor)/login/page.tsx index 76c2b2609..b06967984 100644 --- a/frontend/src/app/(editor)/login/page.tsx +++ b/frontend/src/app/(editor)/login/page.tsx @@ -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 ( - - - - -
- -
- -
-
- -
- -
-
- - - - - - - -
-
+ + + + + + ) } diff --git a/frontend/src/app/(editor)/new-user/page.tsx b/frontend/src/app/(editor)/new-user/page.tsx new file mode 100644 index 000000000..0042bc0ef --- /dev/null +++ b/frontend/src/app/(editor)/new-user/page.tsx @@ -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 + } + + return ( + + + + ) +} + +export default NewUserPage diff --git a/frontend/src/components/common/fields/display-name-field.tsx b/frontend/src/components/common/fields/display-name-field.tsx index 8a8e0059c..1f0416cb9 100644 --- a/frontend/src/components/common/fields/display-name-field.tsx +++ b/frontend/src/components/common/fields/display-name-field.tsx @@ -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 = ({ onChange, value, initialValue }) => { +export const DisplayNameField: React.FC = ({ + 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 ( @@ -37,6 +50,7 @@ export const DisplayNameField: React.FC = ({ onChange, va onChange={onChange} placeholder={placeholderText} autoComplete='name' + disabled={!profileEditsAllowed} required /> diff --git a/frontend/src/components/common/fields/fields.ts b/frontend/src/components/common/fields/fields.ts index cef427f16..acee37e57 100644 --- a/frontend/src/components/common/fields/fields.ts +++ b/frontend/src/components/common/fields/fields.ts @@ -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) => void - value: string +export interface CommonFieldProps { + onChange: ValueType extends undefined ? ChangeEventHandler : (set: ValueType) => void + value: ValueType extends undefined ? string : ValueType hasError?: boolean + disabled?: boolean } diff --git a/frontend/src/components/common/fields/new-password-field.tsx b/frontend/src/components/common/fields/new-password-field.tsx index 8bb930df4..1987846a9 100644 --- a/frontend/src/components/common/fields/new-password-field.tsx +++ b/frontend/src/components/common/fields/new-password-field.tsx @@ -16,7 +16,7 @@ import { Trans } from 'react-i18next' * @param value The currently entered password. */ export const NewPasswordField: React.FC = ({ onChange, value, hasError = false }) => { - const isValid = useMemo(() => value.trim() !== '', [value]) + const isValid = useMemo(() => value.length >= 6, [value]) const placeholderText = useTranslatedText('login.auth.password') diff --git a/frontend/src/components/common/fields/profile-picture-select-field.tsx b/frontend/src/components/common/fields/profile-picture-select-field.tsx new file mode 100644 index 000000000..8f6e215d3 --- /dev/null +++ b/frontend/src/components/common/fields/profile-picture-select-field.tsx @@ -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 { + 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 = ({ + 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 ( + + + + + {pictureUrl && ( + + + + {/* eslint-disable-next-line @next/next/no-img-element */} + {'Profile + + + )} + + + + {/* eslint-disable-next-line @next/next/no-img-element */} + {'Fallback + + + + + + + ) +} diff --git a/frontend/src/components/common/fields/username-field.tsx b/frontend/src/components/common/fields/username-field.tsx index cf3a886ef..9e89a5999 100644 --- a/frontend/src/components/common/fields/username-field.tsx +++ b/frontend/src/components/common/fields/username-field.tsx @@ -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 = ({ onChange, value, isValid, isInvalid }) => { +export const UsernameField: React.FC = ({ onChange, value, isValid, isInvalid, disabled }) => { const placeholderText = useTranslatedText('login.auth.username') return ( @@ -29,10 +30,12 @@ export const UsernameField: React.FC = ({ 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 diff --git a/frontend/src/components/common/fields/username-label-field.tsx b/frontend/src/components/common/fields/username-label-field.tsx index 06856f485..573cbc8b2 100644 --- a/frontend/src/components/common/fields/username-label-field.tsx +++ b/frontend/src/components/common/fields/username-label-field.tsx @@ -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 = (props) => { +export const UsernameLabelField: React.FC = ({ 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 ( - + diff --git a/frontend/src/components/common/user-avatar/hooks/use-avatar-url.ts b/frontend/src/components/common/user-avatar/hooks/use-avatar-url.ts index bcb2d0f68..ecce10e3f 100644 --- a/frontend/src/components/common/user-avatar/hooks/use-avatar-url.ts +++ b/frontend/src/components/common/user-avatar/hooks/use-avatar-url.ts @@ -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]) } diff --git a/frontend/src/components/common/user-avatar/user-avatar-for-user.tsx b/frontend/src/components/common/user-avatar/user-avatar-for-user.tsx index c576d76b9..98566f661 100644 --- a/frontend/src/components/common/user-avatar/user-avatar-for-user.tsx +++ b/frontend/src/components/common/user-avatar/user-avatar-for-user.tsx @@ -19,5 +19,5 @@ export interface UserAvatarForUserProps extends Omit = ({ user, ...props }) => { - return + return } diff --git a/frontend/src/components/common/user-avatar/user-avatar-for-username.tsx b/frontend/src/components/common/user-avatar/user-avatar-for-username.tsx index 5bd329818..7a282a9cb 100644 --- a/frontend/src/components/common/user-avatar/user-avatar-for-username.tsx +++ b/frontend/src/components/common/user-avatar/user-avatar-for-username.tsx @@ -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 = ({ 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 ? : - }, [props, value]) + if (!value) { + return null + } + return + }, [props, value, username]) return ( diff --git a/frontend/src/components/common/user-avatar/user-avatar.tsx b/frontend/src/components/common/user-avatar/user-avatar.tsx index 6294daeb5..2447d39c9 100644 --- a/frontend/src/components/common/user-avatar/user-avatar.tsx +++ b/frontend/src/components/common/user-avatar/user-avatar.tsx @@ -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 = ({ displayName, size, additionalClasses = '', - showName = true + showName = true, + username }) => { const imageSize = useMemo(() => { switch (size) { @@ -42,7 +44,7 @@ export const UserAvatar: React.FC = ({ } }, [size]) - const avatarUrl = useAvatarUrl(photoUrl, displayName) + const avatarUrl = useAvatarUrl(photoUrl, username ?? displayName) const imageTranslateOptions = useMemo( () => ({ diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-user.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-user.tsx index ef7b6d640..413a61915 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-user.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/permissions-sidebar-entry/permissions-modal/permission-entry-user.tsx @@ -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 { - return await getUser(entry.username) + return await getUserInfo(entry.username) }, [entry.username]) if (!value) { diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/utils.ts b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/utils.ts index 740e2d2be..0b3445643 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/utils.ts +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/utils.ts @@ -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 { const onSignOut = useCallback(() => { clearUser() doLogout() - .then(() => router.push('/login')) + .then((logoutResponse) => router.push(logoutResponse.redirect)) .catch(showErrorNotification('login.logoutFailed')) }, [showErrorNotification, router]) diff --git a/frontend/src/components/layout/login-layout.tsx b/frontend/src/components/layout/login-layout.tsx new file mode 100644 index 000000000..59138347f --- /dev/null +++ b/frontend/src/components/layout/login-layout.tsx @@ -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 = ({ children }) => { + return ( + + + + +
+ +
+ +
+
+ +
+ +
+
+ + + {children} + +
+
+ ) +} diff --git a/frontend/src/components/login-page/ldap/ldap-login-card.tsx b/frontend/src/components/login-page/ldap/ldap-login-card.tsx index 39cd54211..6e744f794 100644 --- a/frontend/src/components/login-page/ldap/ldap-login-card.tsx +++ b/frontend/src/components/login-page/ldap/ldap-login-card.tsx @@ -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 = ({ providerName, identifier }) => { useTranslation() + const router = useRouter() const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState() 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 = ({ providerName, identifier
- - + + - + {error} + + + + + + ) +} diff --git a/frontend/src/components/login-page/one-click/get-one-click-provider-metadata.ts b/frontend/src/components/login-page/one-click/get-one-click-provider-metadata.ts index f57515f42..6b1a2bd5b 100644 --- a/frontend/src/components/login-page/one-click/get-one-click-provider-metadata.ts +++ b/frontend/src/components/login-page/one-click/get-one-click-provider-metadata.ts @@ -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}` } } diff --git a/frontend/src/components/login-page/one-click/one-click-login-button.module.scss b/frontend/src/components/login-page/one-click/one-click-login-button.module.scss new file mode 100644 index 000000000..37e2fe11d --- /dev/null +++ b/frontend/src/components/login-page/one-click/one-click-login-button.module.scss @@ -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); +} diff --git a/frontend/src/components/login-page/one-click/via-one-click.module.scss b/frontend/src/components/login-page/one-click/via-one-click.module.scss deleted file mode 100644 index 07d8d54ad..000000000 --- a/frontend/src/components/login-page/one-click/via-one-click.module.scss +++ /dev/null @@ -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); -} diff --git a/frontend/src/components/login-page/password-field.tsx b/frontend/src/components/login-page/password-field.tsx index 5de5d86e8..7743b753a 100644 --- a/frontend/src/components/login-page/password-field.tsx +++ b/frontend/src/components/login-page/password-field.tsx @@ -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) => 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 = ({ onChange, invalid }) => { +export const PasswordField: React.FC = ({ onChange, isInvalid }) => { const placeholderText = useTranslatedText('login.auth.password') return ( { - const redirectBackUrl = useSingleStringUrlParameter('redirectBackTo', defaultFallback) - - const cleanedUrl = - redirectBackUrl.startsWith('/') && !redirectBackUrl.startsWith('//') ? redirectBackUrl : defaultFallback - - return + const redirectUrl = useGetPostLoginRedirectUrl() + return } diff --git a/frontend/src/components/login-page/utils/use-get-post-login-redirect-url.ts b/frontend/src/components/login-page/utils/use-get-post-login-redirect-url.ts new file mode 100644 index 000000000..3a766edf1 --- /dev/null +++ b/frontend/src/components/login-page/utils/use-get-post-login-redirect-url.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { useSingleStringUrlParameter } from '../../../hooks/common/use-single-string-url-parameter' + +const defaultFallback = '/history' + +/** + * Returns the URL that the user should be redirected to after logging in. + * If no parameter has been provided or if the URL is not relative, then "/history" will be used. + */ +export const useGetPostLoginRedirectUrl = (): string => { + const redirectBackUrl = useSingleStringUrlParameter('redirectBackTo', defaultFallback) + return redirectBackUrl.startsWith('/') && !redirectBackUrl.startsWith('//') ? redirectBackUrl : defaultFallback +} diff --git a/frontend/src/pages/api/private/auth/logout.ts b/frontend/src/pages/api/private/auth/logout.ts index e24a08988..a56102c4b 100644 --- a/frontend/src/pages/api/private/auth/logout.ts +++ b/frontend/src/pages/api/private/auth/logout.ts @@ -7,7 +7,9 @@ import type { NextApiRequest, NextApiResponse } from 'next' const handler = (req: NextApiRequest, res: NextApiResponse) => { - res.setHeader('Set-Cookie', 'mock-session=0; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT').status(200).json({}) + res.setHeader('Set-Cookie', 'mock-session=0; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT').status(200).json({ + redirect: '/' + }) } export default handler diff --git a/frontend/src/pages/api/private/config.ts b/frontend/src/pages/api/private/config.ts index 7cd0c11ba..68c1f664e 100644 --- a/frontend/src/pages/api/private/config.ts +++ b/frontend/src/pages/api/private/config.ts @@ -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,6 +15,8 @@ import type { NextApiRequest, NextApiResponse } from 'next' const initialConfig: FrontendConfig = { allowRegister: true, + allowProfileEdits: true, + allowChooseUsername: true, branding: { name: 'DEMO Corp', logo: '/public/img/demo.png' @@ -39,31 +41,15 @@ const initialConfig: FrontendConfig = { { type: AuthProviderType.LOCAL }, - { - type: AuthProviderType.GITHUB - }, - { - type: AuthProviderType.GOOGLE - }, { type: AuthProviderType.LDAP, identifier: 'test-ldap', providerName: 'Test LDAP' }, { - type: AuthProviderType.GITLAB, - identifier: 'test-gitlab', - providerName: 'Test GitLab' - }, - { - type: AuthProviderType.OAUTH2, - identifier: 'test-oauth2', - providerName: 'Test OAuth2' - }, - { - type: AuthProviderType.SAML, - identifier: 'test-saml', - providerName: 'Test SAML' + type: AuthProviderType.OIDC, + identifier: 'test-oidc', + providerName: 'Test OIDC' } ] } diff --git a/frontend/src/pages/api/private/users/erik.ts b/frontend/src/pages/api/private/users/profile/erik.ts similarity index 71% rename from frontend/src/pages/api/private/users/erik.ts rename to frontend/src/pages/api/private/users/profile/erik.ts index bd3855b88..280633992 100644 --- a/frontend/src/pages/api/private/users/erik.ts +++ b/frontend/src/pages/api/private/users/profile/erik.ts @@ -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 { UserInfo } from '../../../../api/users/types' -import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' +import type { UserInfo } from '../../../../../api/users/types' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' import type { NextApiRequest, NextApiResponse } from 'next' const handler = (req: NextApiRequest, res: NextApiResponse): void => { diff --git a/frontend/src/pages/api/private/users/mock.ts b/frontend/src/pages/api/private/users/profile/mock.ts similarity index 70% rename from frontend/src/pages/api/private/users/mock.ts rename to frontend/src/pages/api/private/users/profile/mock.ts index 2a790f40e..fb9fe14c0 100644 --- a/frontend/src/pages/api/private/users/mock.ts +++ b/frontend/src/pages/api/private/users/profile/mock.ts @@ -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 { UserInfo } from '../../../../api/users/types' -import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' +import type { UserInfo } from '../../../../../api/users/types' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' import type { NextApiRequest, NextApiResponse } from 'next' const handler = (req: NextApiRequest, res: NextApiResponse): void => { diff --git a/frontend/src/pages/api/private/users/molly.ts b/frontend/src/pages/api/private/users/profile/molly.ts similarity index 71% rename from frontend/src/pages/api/private/users/molly.ts rename to frontend/src/pages/api/private/users/profile/molly.ts index ee8d58087..f52f427aa 100644 --- a/frontend/src/pages/api/private/users/molly.ts +++ b/frontend/src/pages/api/private/users/profile/molly.ts @@ -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 { UserInfo } from '../../../../api/users/types' -import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' +import type { UserInfo } from '../../../../../api/users/types' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' import type { NextApiRequest, NextApiResponse } from 'next' const handler = (req: NextApiRequest, res: NextApiResponse): void => { diff --git a/frontend/src/pages/api/private/users/tilman.ts b/frontend/src/pages/api/private/users/profile/tilman.ts similarity index 71% rename from frontend/src/pages/api/private/users/tilman.ts rename to frontend/src/pages/api/private/users/profile/tilman.ts index 039860086..d71bff512 100644 --- a/frontend/src/pages/api/private/users/tilman.ts +++ b/frontend/src/pages/api/private/users/profile/tilman.ts @@ -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 { UserInfo } from '../../../../api/users/types' -import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request' +import type { UserInfo } from '../../../../../api/users/types' +import { HttpMethod, respondToMatchingRequest } from '../../../../../handler-utils/respond-to-matching-request' import type { NextApiRequest, NextApiResponse } from 'next' const handler = (req: NextApiRequest, res: NextApiResponse): void => { diff --git a/yarn.lock b/yarn.lock index 88c180fd1..71f4b9e9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2561,6 +2561,7 @@ __metadata: mocked-env: "npm:1.3.5" mysql: "npm:2.18.1" node-fetch: "npm:2.7.0" + openid-client: "npm:5.6.5" passport: "npm:0.7.0" passport-custom: "npm:1.1.1" passport-http-bearer: "npm:1.0.1" @@ -12517,6 +12518,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^4.15.5": + version: 4.15.9 + resolution: "jose@npm:4.15.9" + checksum: 10c0/4ed4ddf4a029db04bd167f2215f65d7245e4dc5f36d7ac3c0126aab38d66309a9e692f52df88975d99429e357e5fd8bab340ff20baab544d17684dd1d940a0f4 + languageName: node + linkType: hard + "js-cookie@npm:^2.2.1": version: 2.2.1 resolution: "js-cookie@npm:2.2.1" @@ -14233,6 +14241,13 @@ __metadata: languageName: node linkType: hard +"object-hash@npm:^2.2.0": + version: 2.2.0 + resolution: "object-hash@npm:2.2.0" + checksum: 10c0/1527de843926c5442ed61f8bdddfc7dc181b6497f725b0e89fcf50a55d9c803088763ed447cac85a5aa65345f1e99c2469ba679a54349ef3c4c0aeaa396a3eb9 + languageName: node + linkType: hard + "object-inspect@npm:^1.13.1": version: 1.13.1 resolution: "object-inspect@npm:1.13.1" @@ -14322,6 +14337,13 @@ __metadata: languageName: node linkType: hard +"oidc-token-hash@npm:^5.0.3": + version: 5.0.3 + resolution: "oidc-token-hash@npm:5.0.3" + checksum: 10c0/d0dc0551406f09577874155cc83cf69c39e4b826293d50bb6c37936698aeca17d4bcee356ab910c859e53e83f2728a2acbd041020165191353b29de51fbca615 + languageName: node + linkType: hard + "on-finished@npm:2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -14365,6 +14387,18 @@ __metadata: languageName: node linkType: hard +"openid-client@npm:5.6.5": + version: 5.6.5 + resolution: "openid-client@npm:5.6.5" + dependencies: + jose: "npm:^4.15.5" + lru-cache: "npm:^6.0.0" + object-hash: "npm:^2.2.0" + oidc-token-hash: "npm:^5.0.3" + checksum: 10c0/4308dcd37a9ffb1efc2ede0bc556ae42ccc2569e71baa52a03ddfa44407bf403d4534286f6f571381c5eaa1845c609ed699a5eb0d350acfb8c3bacb72c2a6890 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.3 resolution: "optionator@npm:0.9.3"