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

Thanks to all HedgeDoc team members for the time discussing,
helping with weird Nest issues, providing feedback
and suggestions!

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2024-03-23 02:10:25 +01:00
parent 1609f3e01f
commit 7f665fae4b
109 changed files with 2927 additions and 1700 deletions

View file

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

View file

@ -0,0 +1,84 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
Body,
Controller,
InternalServerErrorException,
Param,
Post,
Req,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NotInDBError } from '../../../../errors/errors';
import { IdentityService } from '../../../../identity/identity.service';
import { LdapLoginDto } from '../../../../identity/ldap/ldap-login.dto';
import { LdapService } from '../../../../identity/ldap/ldap.service';
import { ProviderType } from '../../../../identity/provider-type.enum';
import { RequestWithSession } from '../../../../identity/session.guard';
import { ConsoleLoggerService } from '../../../../logger/console-logger.service';
import { UsersService } from '../../../../users/users.service';
import { makeUsernameLowercase } from '../../../../utils/username';
import { OpenApi } from '../../../utils/openapi.decorator';
@ApiTags('auth')
@Controller('/auth/ldap')
export class LdapController {
constructor(
private readonly logger: ConsoleLoggerService,
private usersService: UsersService,
private ldapService: LdapService,
private identityService: IdentityService,
) {
this.logger.setContext(LdapController.name);
}
@Post(':ldapIdentifier/login')
@OpenApi(200, 400, 401)
async loginWithLdap(
@Req()
request: RequestWithSession,
@Param('ldapIdentifier') ldapIdentifier: string,
@Body() loginDto: LdapLoginDto,
): Promise<{ newUser: boolean }> {
const ldapConfig = this.ldapService.getLdapConfig(ldapIdentifier);
const userInfo = await this.ldapService.getUserInfoFromLdap(
ldapConfig,
loginDto.username,
loginDto.password,
);
try {
request.session.authProviderType = ProviderType.LDAP;
request.session.authProviderIdentifier = ldapIdentifier;
request.session.providerUserId = userInfo.id;
await this.identityService.getIdentityFromUserIdAndProviderType(
userInfo.id,
ProviderType.LDAP,
ldapIdentifier,
);
if (this.identityService.mayUpdateIdentity(ldapIdentifier)) {
const user = await this.usersService.getUserByUsername(
makeUsernameLowercase(loginDto.username),
);
await this.usersService.updateUser(
user,
userInfo.displayName,
userInfo.email,
userInfo.photoUrl,
);
}
request.session.username = makeUsernameLowercase(loginDto.username);
return { newUser: false };
} catch (error) {
if (error instanceof NotInDBError) {
request.session.newUserData = userInfo;
return { newUser: true };
}
this.logger.error(`Error during LDAP login: ${String(error)}`);
throw new InternalServerErrorException('Error during LDAP login');
}
}
}

View file

@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
Body,
Controller,
Post,
Put,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { LocalService } from '../../../../identity/local/local.service';
import { LoginDto } from '../../../../identity/local/login.dto';
import { RegisterDto } from '../../../../identity/local/register.dto';
import { UpdatePasswordDto } from '../../../../identity/local/update-password.dto';
import { ProviderType } from '../../../../identity/provider-type.enum';
import {
RequestWithSession,
SessionGuard,
} from '../../../../identity/session.guard';
import { ConsoleLoggerService } from '../../../../logger/console-logger.service';
import { User } from '../../../../users/user.entity';
import { UsersService } from '../../../../users/users.service';
import { LoginEnabledGuard } from '../../../utils/login-enabled.guard';
import { OpenApi } from '../../../utils/openapi.decorator';
import { RegistrationEnabledGuard } from '../../../utils/registration-enabled.guard';
import { RequestUser } from '../../../utils/request-user.decorator';
@ApiTags('auth')
@Controller('/auth/local')
export class LocalController {
constructor(
private readonly logger: ConsoleLoggerService,
private usersService: UsersService,
private localIdentityService: LocalService,
) {
this.logger.setContext(LocalController.name);
}
@UseGuards(RegistrationEnabledGuard)
@Post()
@OpenApi(201, 400, 403, 409)
async registerUser(
@Req() request: RequestWithSession,
@Body() registerDto: RegisterDto,
): Promise<void> {
await this.localIdentityService.checkPasswordStrength(registerDto.password);
const user = await this.usersService.createUser(
registerDto.username,
registerDto.displayName,
);
await this.localIdentityService.createLocalIdentity(
user,
registerDto.password,
);
// Log the user in after registration
request.session.authProviderType = ProviderType.LOCAL;
request.session.username = registerDto.username;
}
@UseGuards(LoginEnabledGuard, SessionGuard)
@Put()
@OpenApi(200, 400, 401)
async updatePassword(
@RequestUser() user: User,
@Body() changePasswordDto: UpdatePasswordDto,
): Promise<void> {
await this.localIdentityService.checkLocalPassword(
user,
changePasswordDto.currentPassword,
);
await this.localIdentityService.updateLocalPassword(
user,
changePasswordDto.newPassword,
);
}
@UseGuards(LoginEnabledGuard)
@Post('login')
@OpenApi(201, 400, 401)
async login(
@Req()
request: RequestWithSession,
@Body() loginDto: LoginDto,
): Promise<void> {
try {
const user = await this.usersService.getUserByUsername(loginDto.username);
await this.localIdentityService.checkLocalPassword(
user,
loginDto.password,
);
request.session.username = loginDto.username;
request.session.authProviderType = ProviderType.LOCAL;
} catch (error) {
this.logger.error(`Failed to log in user: ${String(error)}`);
throw new UnauthorizedException('Invalid username or password');
}
}
}

View file

@ -0,0 +1,101 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
Controller,
Get,
Param,
Redirect,
Req,
UnauthorizedException,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { IdentityService } from '../../../../identity/identity.service';
import { OidcService } from '../../../../identity/oidc/oidc.service';
import { ProviderType } from '../../../../identity/provider-type.enum';
import { RequestWithSession } from '../../../../identity/session.guard';
import { ConsoleLoggerService } from '../../../../logger/console-logger.service';
import { UsersService } from '../../../../users/users.service';
import { OpenApi } from '../../../utils/openapi.decorator';
@ApiTags('auth')
@Controller('/auth/oidc')
export class OidcController {
constructor(
private readonly logger: ConsoleLoggerService,
private usersService: UsersService,
private identityService: IdentityService,
private oidcService: OidcService,
) {
this.logger.setContext(OidcController.name);
}
@Get(':oidcIdentifier')
@Redirect()
@OpenApi(201, 400, 401)
loginWithOpenIdConnect(
@Req() request: RequestWithSession,
@Param('oidcIdentifier') oidcIdentifier: string,
): { url: string } {
const code = this.oidcService.generateCode();
request.session.oidcLoginCode = code;
request.session.authProviderType = ProviderType.OIDC;
request.session.authProviderIdentifier = oidcIdentifier;
const authorizationUrl = this.oidcService.getAuthorizationUrl(
oidcIdentifier,
code,
);
return { url: authorizationUrl };
}
@Get(':oidcIdentifier/callback')
@Redirect()
@OpenApi(201, 400, 401)
async callback(
@Param('oidcIdentifier') oidcIdentifier: string,
@Req() request: RequestWithSession,
): Promise<{ url: string }> {
try {
const userInfo = await this.oidcService.extractUserInfoFromCallback(
oidcIdentifier,
request,
);
const oidcUserIdentifier = request.session.providerUserId;
if (!oidcUserIdentifier) {
throw new Error('No OIDC user identifier found');
}
const identity = await this.oidcService.getExistingOidcIdentity(
oidcIdentifier,
oidcUserIdentifier,
);
request.session.authProviderType = ProviderType.OIDC;
const mayUpdate = this.identityService.mayUpdateIdentity(oidcIdentifier);
if (identity !== null) {
const user = await identity.user;
if (mayUpdate) {
await this.usersService.updateUser(
user,
userInfo.displayName,
userInfo.email,
userInfo.photoUrl,
);
}
request.session.username = user.username;
return { url: '/' };
} else {
request.session.newUserData = userInfo;
return { url: '/new-user' };
}
} catch (error) {
this.logger.log(
'Error during OIDC callback:' + String(error),
'callback',
);
throw new UnauthorizedException();
}
}
}

View file

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

View file

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

View file

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

View file

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