Merge pull request #1517 from hedgedoc/privateApi/auth/email

Add local login with username and password
This commit is contained in:
Yannick Bungers 2021-09-17 00:06:17 +02:00 committed by GitHub
commit 83f0bbb986
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1352 additions and 178 deletions

View file

@ -40,8 +40,9 @@ entity "identity" {
*id : number
--
*userId : uuid <<FK user>>
*providerType: text
' Identifies the external login provider and is set in the config
*providerName : text
providerName : text
*syncSource : boolean
*createdAt : date
*updatedAt : date

View file

@ -47,6 +47,7 @@
"connect-typeorm": "1.1.4",
"eslint-plugin-jest": "24.4.0",
"eslint-plugin-local-rules": "1.1.0",
"express-session": "1.17.2",
"file-type": "16.5.3",
"joi": "17.4.2",
"minio": "7.0.19",
@ -54,6 +55,7 @@
"node-fetch": "2.6.2",
"passport": "0.4.1",
"passport-http-bearer": "1.0.1",
"passport-local": "1.0.0",
"raw-body": "2.4.1",
"reflect-metadata": "0.1.13",
"rimraf": "3.0.2",
@ -70,8 +72,10 @@
"@tsconfig/node12": "1.0.9",
"@types/cli-color": "2.0.1",
"@types/express": "4.17.13",
"@types/express-session": "^1.17.4",
"@types/jest": "27.0.1",
"@types/node": "14.17.16",
"@types/passport-local": "^1.0.34",
"@types/supertest": "2.0.11",
"@typescript-eslint/eslint-plugin": "4.31.1",
"@typescript-eslint/parser": "4.31.1",

View file

@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: 2021 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 { getConnectionToken, 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 { Identity } from '../../../identity/identity.entity';
import { IdentityModule } from '../../../identity/identity.module';
import { LoggerModule } from '../../../logger/logger.module';
import { Session } from '../../../users/session.entity';
import { User } from '../../../users/user.entity';
import { UsersModule } from '../../../users/users.module';
import { AuthController } from './auth.controller';
describe('AuthController', () => {
let controller: AuthController;
type MockConnection = {
transaction: () => void;
};
function mockConnection(): MockConnection {
return {
transaction: jest.fn(),
};
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: getRepositoryToken(Identity),
useClass: Repository,
},
{
provide: getConnectionToken(),
useFactory: mockConnection,
},
],
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfigMock, authConfigMock],
}),
LoggerModule,
UsersModule,
IdentityModule,
],
controllers: [AuthController],
})
.overrideProvider(getRepositoryToken(Identity))
.useClass(Repository)
.overrideProvider(getRepositoryToken(Session))
.useValue({})
.overrideProvider(getRepositoryToken(User))
.useValue({})
.compile();
controller = module.get<AuthController>(AuthController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View file

@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
BadRequestException,
Body,
Controller,
Delete,
NotFoundException,
Post,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { Session } from 'express-session';
import { AlreadyInDBError, NotInDBError } from '../../../errors/errors';
import { IdentityService } from '../../../identity/identity.service';
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 { 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 { RegistrationEnabledGuard } from '../../utils/registration-enabled.guard';
import { RequestUser } from '../../utils/request-user.decorator';
@Controller('auth')
export class AuthController {
constructor(
private readonly logger: ConsoleLoggerService,
private usersService: UsersService,
private identityService: IdentityService,
) {
this.logger.setContext(AuthController.name);
}
@UseGuards(RegistrationEnabledGuard)
@Post('local')
async registerUser(@Body() registerDto: RegisterDto): Promise<void> {
try {
const user = await this.usersService.createUser(
registerDto.username,
registerDto.displayname,
);
// ToDo: Figure out how to rollback user if anything with this calls goes wrong
await this.identityService.createLocalIdentity(
user,
registerDto.password,
);
return;
} catch (e) {
if (e instanceof AlreadyInDBError) {
throw new BadRequestException(e.message);
}
throw e;
}
}
@UseGuards(LoginEnabledGuard, SessionGuard)
@Put('local')
async updatePassword(
@RequestUser() user: User,
@Body() changePasswordDto: UpdatePasswordDto,
): Promise<void> {
try {
await this.identityService.updateLocalPassword(
user,
changePasswordDto.newPassword,
);
return;
} catch (e) {
if (e instanceof NotInDBError) {
throw new NotFoundException(e.message);
}
throw e;
}
}
@UseGuards(LoginEnabledGuard, LocalAuthGuard)
@Post('local/login')
login(
@Req() request: Request & { session: { user: string } },
@Body() loginDto: LoginDto,
): void {
// There is no further testing needed as we only get to this point if LocalAuthGuard was successful
request.session.user = loginDto.username;
}
@UseGuards(SessionGuard)
@Delete('logout')
logout(@Req() request: Request & { session: Session }): void {
request.session.destroy((err) => {
if (err) {
this.logger.error('Encountered an error while logging out: ${err}');
throw new BadRequestException('Unable to log out');
}
});
}
}

View file

@ -17,6 +17,7 @@ import appConfigMock from '../../../../config/mock/app.config.mock';
import { Group } from '../../../../groups/group.entity';
import { HistoryEntry } from '../../../../history/history-entry.entity';
import { HistoryModule } from '../../../../history/history.module';
import { Identity } from '../../../../identity/identity.entity';
import { LoggerModule } from '../../../../logger/logger.module';
import { Note } from '../../../../notes/note.entity';
import { NotesModule } from '../../../../notes/notes.module';
@ -25,7 +26,6 @@ import { NoteGroupPermission } from '../../../../permissions/note-group-permissi
import { NoteUserPermission } from '../../../../permissions/note-user-permission.entity';
import { Edit } from '../../../../revisions/edit.entity';
import { Revision } from '../../../../revisions/revision.entity';
import { Identity } from '../../../../users/identity.entity';
import { Session } from '../../../../users/session.entity';
import { User } from '../../../../users/user.entity';
import { UsersModule } from '../../../../users/users.module';

View file

@ -14,6 +14,7 @@ import customizationConfigMock from '../../../config/mock/customization.config.m
import externalServicesConfigMock from '../../../config/mock/external-services.config.mock';
import mediaConfigMock from '../../../config/mock/media.config.mock';
import { Group } from '../../../groups/group.entity';
import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module';
@ -23,7 +24,6 @@ import { NoteGroupPermission } from '../../../permissions/note-group-permission.
import { NoteUserPermission } from '../../../permissions/note-user-permission.entity';
import { Edit } from '../../../revisions/edit.entity';
import { Revision } from '../../../revisions/revision.entity';
import { Identity } from '../../../users/identity.entity';
import { Session } from '../../../users/session.entity';
import { User } from '../../../users/user.entity';
import { UsersModule } from '../../../users/users.module';

View file

@ -15,6 +15,7 @@ import customizationConfigMock from '../../../config/mock/customization.config.m
import externalConfigMock from '../../../config/mock/external-services.config.mock';
import mediaConfigMock from '../../../config/mock/media.config.mock';
import { Group } from '../../../groups/group.entity';
import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module';
@ -25,7 +26,6 @@ import { NoteGroupPermission } from '../../../permissions/note-group-permission.
import { NoteUserPermission } from '../../../permissions/note-user-permission.entity';
import { Edit } from '../../../revisions/edit.entity';
import { Revision } from '../../../revisions/revision.entity';
import { Identity } from '../../../users/identity.entity';
import { Session } from '../../../users/session.entity';
import { User } from '../../../users/user.entity';
import { UsersModule } from '../../../users/users.module';

View file

@ -19,6 +19,7 @@ import { Group } from '../../../groups/group.entity';
import { GroupsModule } from '../../../groups/groups.module';
import { HistoryEntry } from '../../../history/history-entry.entity';
import { HistoryModule } from '../../../history/history.module';
import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module';
@ -31,7 +32,6 @@ import { PermissionsModule } from '../../../permissions/permissions.module';
import { Edit } from '../../../revisions/edit.entity';
import { Revision } from '../../../revisions/revision.entity';
import { RevisionsModule } from '../../../revisions/revisions.module';
import { Identity } from '../../../users/identity.entity';
import { Session } from '../../../users/session.entity';
import { User } from '../../../users/user.entity';
import { UsersModule } from '../../../users/users.module';

View file

@ -8,12 +8,14 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { FrontendConfigModule } from '../../frontend-config/frontend-config.module';
import { HistoryModule } from '../../history/history.module';
import { IdentityModule } from '../../identity/identity.module';
import { LoggerModule } from '../../logger/logger.module';
import { MediaModule } from '../../media/media.module';
import { NotesModule } from '../../notes/notes.module';
import { PermissionsModule } from '../../permissions/permissions.module';
import { RevisionsModule } from '../../revisions/revisions.module';
import { UsersModule } from '../../users/users.module';
import { AuthController } from './auth/auth.controller';
import { ConfigController } from './config/config.controller';
import { HistoryController } from './me/history/history.controller';
import { MeController } from './me/me.controller';
@ -32,6 +34,7 @@ import { TokensController } from './tokens/tokens.controller';
NotesModule,
MediaModule,
RevisionsModule,
IdentityModule,
],
controllers: [
TokensController,
@ -40,6 +43,7 @@ import { TokensController } from './tokens/tokens.controller';
HistoryController,
MeController,
NotesController,
AuthController,
],
})
export class PrivateApiModule {}

View file

@ -10,8 +10,8 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { AuthToken } from '../../../auth/auth-token.entity';
import { AuthModule } from '../../../auth/auth.module';
import appConfigMock from '../../../config/mock/app.config.mock';
import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module';
import { Identity } from '../../../users/identity.entity';
import { Session } from '../../../users/session.entity';
import { User } from '../../../users/user.entity';
import { TokensController } from './tokens.controller';

View file

@ -18,6 +18,7 @@ import mediaConfigMock from '../../../config/mock/media.config.mock';
import { Group } from '../../../groups/group.entity';
import { HistoryEntry } from '../../../history/history-entry.entity';
import { HistoryModule } from '../../../history/history.module';
import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module';
@ -28,7 +29,6 @@ import { NoteGroupPermission } from '../../../permissions/note-group-permission.
import { NoteUserPermission } from '../../../permissions/note-user-permission.entity';
import { Edit } from '../../../revisions/edit.entity';
import { Revision } from '../../../revisions/revision.entity';
import { Identity } from '../../../users/identity.entity';
import { Session } from '../../../users/session.entity';
import { User } from '../../../users/user.entity';
import { UsersModule } from '../../../users/users.module';

View file

@ -12,6 +12,7 @@ import { Author } from '../../../authors/author.entity';
import appConfigMock from '../../../config/mock/app.config.mock';
import mediaConfigMock from '../../../config/mock/media.config.mock';
import { Group } from '../../../groups/group.entity';
import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module';
@ -22,7 +23,6 @@ import { NoteGroupPermission } from '../../../permissions/note-group-permission.
import { NoteUserPermission } from '../../../permissions/note-user-permission.entity';
import { Edit } from '../../../revisions/edit.entity';
import { Revision } from '../../../revisions/revision.entity';
import { Identity } from '../../../users/identity.entity';
import { Session } from '../../../users/session.entity';
import { User } from '../../../users/user.entity';
import { MediaController } from './media.controller';

View file

@ -19,6 +19,7 @@ import { Group } from '../../../groups/group.entity';
import { GroupsModule } from '../../../groups/groups.module';
import { HistoryEntry } from '../../../history/history-entry.entity';
import { HistoryModule } from '../../../history/history.module';
import { Identity } from '../../../identity/identity.entity';
import { LoggerModule } from '../../../logger/logger.module';
import { MediaUpload } from '../../../media/media-upload.entity';
import { MediaModule } from '../../../media/media.module';
@ -31,7 +32,6 @@ import { PermissionsModule } from '../../../permissions/permissions.module';
import { Edit } from '../../../revisions/edit.entity';
import { Revision } from '../../../revisions/revision.entity';
import { RevisionsModule } from '../../../revisions/revisions.module';
import { Identity } from '../../../users/identity.entity';
import { Session } from '../../../users/session.entity';
import { User } from '../../../users/user.entity';
import { UsersModule } from '../../../users/users.module';

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
BadRequestException,
CanActivate,
Inject,
Injectable,
} from '@nestjs/common';
import authConfiguration, { AuthConfig } from '../../config/auth.config';
import { ConsoleLoggerService } from '../../logger/console-logger.service';
@Injectable()
export class LoginEnabledGuard implements CanActivate {
constructor(
private readonly logger: ConsoleLoggerService,
@Inject(authConfiguration.KEY)
private authConfig: AuthConfig,
) {
this.logger.setContext(LoginEnabledGuard.name);
}
canActivate(): boolean {
if (!this.authConfig.local.enableLogin) {
this.logger.debug('Local auth is disabled.', 'canActivate');
throw new BadRequestException('Local auth is disabled.');
}
return true;
}
}

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
BadRequestException,
CanActivate,
Inject,
Injectable,
} from '@nestjs/common';
import authConfiguration, { AuthConfig } from '../../config/auth.config';
import { ConsoleLoggerService } from '../../logger/console-logger.service';
@Injectable()
export class RegistrationEnabledGuard implements CanActivate {
constructor(
private readonly logger: ConsoleLoggerService,
@Inject(authConfiguration.KEY)
private authConfig: AuthConfig,
) {
this.logger.setContext(RegistrationEnabledGuard.name);
}
canActivate(): boolean {
if (!this.authConfig.local.enableRegister) {
this.logger.debug('User registration is disabled.', 'canActivate');
throw new BadRequestException('User registration is disabled.');
}
return true;
}
}

View file

@ -25,6 +25,7 @@ import { FrontendConfigModule } from './frontend-config/frontend-config.module';
import { FrontendConfigService } from './frontend-config/frontend-config.service';
import { GroupsModule } from './groups/groups.module';
import { HistoryModule } from './history/history.module';
import { IdentityModule } from './identity/identity.module';
import { LoggerModule } from './logger/logger.module';
import { MediaModule } from './media/media.module';
import { MonitoringModule } from './monitoring/monitoring.module';
@ -81,6 +82,7 @@ const routes: Routes = [
MediaModule,
AuthModule,
FrontendConfigModule,
IdentityModule,
],
controllers: [],
providers: [FrontendConfigService],

View file

@ -7,16 +7,16 @@ import { ConfigModule } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { randomBytes } from 'crypto';
import { Repository } from 'typeorm';
import appConfigMock from '../config/mock/app.config.mock';
import { NotInDBError, TokenNotValidError } from '../errors/errors';
import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module';
import { Identity } from '../users/identity.entity';
import { Session } from '../users/session.entity';
import { User } from '../users/user.entity';
import { UsersModule } from '../users/users.module';
import { hashPassword } from '../utils/password';
import { AuthToken } from './auth-token.entity';
import { AuthService } from './auth.service';
@ -74,26 +74,6 @@ describe('AuthService', () => {
expect(service).toBeDefined();
});
describe('checkPassword', () => {
it('works', async () => {
const testPassword = 'thisIsATestPassword';
const hash = await service.hashPassword(testPassword);
await service
.checkPassword(testPassword, hash)
.then((result) => expect(result).toBeTruthy());
});
it('fails, if secret is too short', async () => {
const secret = service.bufferToBase64Url(randomBytes(54));
const hash = await service.hashPassword(secret);
await service
.checkPassword(secret, hash)
.then((result) => expect(result).toBeTruthy());
await service
.checkPassword(secret.substr(0, secret.length - 1), hash)
.then((result) => expect(result).toBeFalsy());
});
});
describe('getTokensByUsername', () => {
it('works', async () => {
jest
@ -108,7 +88,7 @@ describe('AuthService', () => {
describe('getAuthToken', () => {
const token = 'testToken';
it('works', async () => {
const accessTokenHash = await service.hashPassword(token);
const accessTokenHash = await hashPassword(token);
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
...authToken,
user: user,
@ -142,7 +122,7 @@ describe('AuthService', () => {
).rejects.toThrow(TokenNotValidError);
});
it('AuthToken has wrong validUntil Date', async () => {
const accessTokenHash = await service.hashPassword(token);
const accessTokenHash = await hashPassword(token);
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
...authToken,
user: user,
@ -185,7 +165,7 @@ describe('AuthService', () => {
describe('validateToken', () => {
it('works', async () => {
const token = 'testToken';
const accessTokenHash = await service.hashPassword(token);
const accessTokenHash = await hashPassword(token);
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce({
...user,
authTokens: [authToken],
@ -303,16 +283,6 @@ describe('AuthService', () => {
});
});
describe('bufferToBase64Url', () => {
it('works', () => {
expect(
service.bufferToBase64Url(
Buffer.from('testsentence is a test sentence'),
),
).toEqual('dGVzdHNlbnRlbmNlIGlzIGEgdGVzdCBzZW50ZW5jZQ');
});
});
describe('toAuthTokenDto', () => {
it('works', () => {
const authToken = new AuthToken();

View file

@ -6,7 +6,6 @@
import { Injectable } from '@nestjs/common';
import { Cron, Timeout } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { compare, hash } from 'bcrypt';
import { randomBytes } from 'crypto';
import { Repository } from 'typeorm';
@ -16,8 +15,14 @@ import {
TooManyTokensError,
} 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 {
bufferToBase64Url,
checkPassword,
hashPassword,
} from '../utils/password';
import { TimestampMillis } from '../utils/timestamp';
import { AuthTokenWithSecretDto } from './auth-token-with-secret.dto';
import { AuthTokenDto } from './auth-token.dto';
@ -52,33 +57,14 @@ export class AuthService {
return await this.usersService.getUserByUsername(accessToken.user.userName);
}
async hashPassword(cleartext: string): Promise<string> {
// hash the password with bcrypt and 2^12 iterations
// this was decided on the basis of https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#bcrypt
return await hash(cleartext, 12);
}
async checkPassword(cleartext: string, password: string): Promise<boolean> {
return await compare(cleartext, password);
}
bufferToBase64Url(text: Buffer): string {
// This is necessary as the is no base64url encoding in the toString method
// but as can be seen on https://tools.ietf.org/html/rfc4648#page-7
// base64url is quite easy buildable from base64
return text
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
async createTokenForUser(
userName: string,
identifier: string,
validUntil: TimestampMillis,
): Promise<AuthTokenWithSecretDto> {
const user = await this.usersService.getUserByUsername(userName, true);
const user = await this.usersService.getUserByUsername(userName, [
UserRelationEnum.AUTHTOKENS,
]);
if (user.authTokens.length >= 200) {
// This is a very high ceiling unlikely to hinder legitimate usage,
// but should prevent possible attack vectors
@ -86,9 +72,9 @@ export class AuthService {
`User '${user.userName}' has already 200 tokens and can't have anymore`,
);
}
const secret = this.bufferToBase64Url(randomBytes(54));
const keyId = this.bufferToBase64Url(randomBytes(8));
const accessToken = await this.hashPassword(secret);
const secret = bufferToBase64Url(randomBytes(54));
const keyId = bufferToBase64Url(randomBytes(8));
const accessToken = await hashPassword(secret);
let token;
// Tokens can only be valid for a maximum of 2 years
const maximumTokenValidity =
@ -138,7 +124,7 @@ export class AuthService {
if (accessToken === undefined) {
throw new NotInDBError(`AuthToken '${token}' not found`);
}
if (!(await this.checkPassword(token, accessToken.accessTokenHash))) {
if (!(await checkPassword(token, accessToken.accessTokenHash))) {
// hashes are not the same
throw new TokenNotValidError(`AuthToken '${token}' is not valid.`);
}
@ -155,7 +141,9 @@ export class AuthService {
}
async getTokensByUsername(userName: string): Promise<AuthToken[]> {
const user = await this.usersService.getUserByUsername(userName, true);
const user = await this.usersService.getUserByUsername(userName, [
UserRelationEnum.AUTHTOKENS,
]);
if (user.authTokens === undefined) {
return [];
}

View file

@ -9,12 +9,17 @@ import * as Joi from 'joi';
import { GitlabScope, GitlabVersion } from './gitlab.enum';
import {
buildErrorMessage,
parseOptionalInt,
replaceAuthErrorsWithEnvironmentVariables,
toArrayConfig,
} from './utils';
export interface AuthConfig {
email: {
session: {
secret: string;
lifetime: number;
};
local: {
enableLogin: boolean;
enableRegister: boolean;
};
@ -101,15 +106,22 @@ export interface AuthConfig {
}
const authSchema = Joi.object({
email: {
session: {
secret: Joi.string().label('HD_SESSION_SECRET'),
lifetime: Joi.number()
.default(1209600000) // 14 * 24 * 60 * 60 * 1000ms = 14 days
.optional()
.label('HD_SESSION_LIFETIME'),
},
local: {
enableLogin: Joi.boolean()
.default(false)
.optional()
.label('HD_AUTH_EMAIL_ENABLE_LOGIN'),
.label('HD_AUTH_LOCAL_ENABLE_LOGIN'),
enableRegister: Joi.boolean()
.default(false)
.optional()
.label('HD_AUTH_EMAIL_ENABLE_REGISTER'),
.label('HD_AUTH_LOCAL_ENABLE_REGISTER'),
},
facebook: {
clientID: Joi.string().optional().label('HD_AUTH_FACEBOOK_CLIENT_ID'),
@ -199,7 +211,7 @@ const authSchema = Joi.object({
attribute: {
id: Joi.string().default('NameId').optional(),
username: Joi.string().default('NameId').optional(),
email: Joi.string().default('NameId').optional(),
local: Joi.string().default('NameId').optional(),
},
}).optional(),
)
@ -297,7 +309,7 @@ export default registerAs('authConfig', () => {
attribute: {
id: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_ID`],
username: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_USERNAME`],
email: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_USERNAME`],
local: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_USERNAME`],
},
};
});
@ -332,9 +344,13 @@ export default registerAs('authConfig', () => {
const authConfig = authSchema.validate(
{
email: {
enableLogin: process.env.HD_AUTH_EMAIL_ENABLE_LOGIN,
enableRegister: process.env.HD_AUTH_EMAIL_ENABLE_REGISTER,
session: {
secret: process.env.HD_SESSION_SECRET,
lifetime: parseOptionalInt(process.env.HD_SESSION_LIFETIME),
},
local: {
enableLogin: process.env.HD_AUTH_LOCAL_ENABLE_LOGIN,
enableRegister: process.env.HD_AUTH_LOCAL_ENABLE_REGISTER,
},
facebook: {
clientID: process.env.HD_AUTH_FACEBOOK_CLIENT_ID,

View file

@ -6,7 +6,11 @@
import { registerAs } from '@nestjs/config';
export default registerAs('authConfig', () => ({
email: {
session: {
secret: 'my_secret',
lifetime: 1209600000,
},
local: {
enableLogin: true,
enableRegister: true,
},

View file

@ -71,10 +71,10 @@ export class AuthProviders {
oauth2: boolean;
/**
* Is internal auth available?
* Is local auth available?
*/
@IsBoolean()
internal: boolean;
local: boolean;
}
export class BrandingDto {

View file

@ -23,7 +23,11 @@ import { FrontendConfigService } from './frontend-config.service';
describe('FrontendConfigService', () => {
const domain = 'http://md.example.com';
const emptyAuthConfig: AuthConfig = {
email: {
session: {
secret: 'my-secret',
lifetime: 1209600000,
},
local: {
enableLogin: false,
enableRegister: false,
},
@ -193,7 +197,7 @@ describe('FrontendConfigService', () => {
};
const authConfig: AuthConfig = {
...emptyAuthConfig,
email: {
local: {
enableLogin,
enableRegister,
},
@ -258,7 +262,7 @@ describe('FrontendConfigService', () => {
expect(config.authProviders.google).toEqual(
!!authConfig.google.clientID,
);
expect(config.authProviders.internal).toEqual(
expect(config.authProviders.local).toEqual(
enableLogin,
);
expect(config.authProviders.twitter).toEqual(

View file

@ -44,7 +44,7 @@ export class FrontendConfigService {
return {
// ToDo: use actual value here
allowAnonymous: false,
allowRegister: this.authConfig.email.enableRegister,
allowRegister: this.authConfig.local.enableRegister,
authProviders: this.getAuthProviders(),
branding: this.getBranding(),
customAuthNames: this.getCustomAuthNames(),
@ -66,7 +66,7 @@ export class FrontendConfigService {
github: !!this.authConfig.github.clientID,
gitlab: this.authConfig.gitlab.length !== 0,
google: !!this.authConfig.google.clientID,
internal: this.authConfig.email.enableLogin,
local: this.authConfig.local.enableLogin,
ldap: this.authConfig.ldap.length !== 0,
oauth2: this.authConfig.oauth2.length !== 0,
saml: this.authConfig.saml.length !== 0,

View file

@ -13,6 +13,7 @@ import { Author } from '../authors/author.entity';
import appConfigMock from '../config/mock/app.config.mock';
import { NotInDBError } from '../errors/errors';
import { Group } from '../groups/group.entity';
import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module';
import { Note } from '../notes/note.entity';
import { NotesModule } from '../notes/notes.module';
@ -21,7 +22,6 @@ import { NoteGroupPermission } from '../permissions/note-group-permission.entity
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
import { Edit } from '../revisions/edit.entity';
import { Revision } from '../revisions/revision.entity';
import { Identity } from '../users/identity.entity';
import { Session } from '../users/session.entity';
import { User } from '../users/user.entity';
import { UsersModule } from '../users/users.module';

View file

@ -0,0 +1,111 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../users/user.entity';
import { ProviderType } from './provider-type.enum';
/**
* The identity represents a single way for a user to login.
* A 'user' can have any number of these.
* Each one holds a type (local, github, twitter, etc.), if this type can have multiple instances (e.g. gitlab),
* it also saves the name of the instance. Also if this identity shall be the syncSource is saved.
*/
@Entity()
export class Identity {
@PrimaryGeneratedColumn()
id: number;
/**
* User that this identity corresponds to
*/
@ManyToOne((_) => User, (user) => user.identities, {
onDelete: 'CASCADE', // This deletes the Identity, when the associated User is deleted
})
user: User;
/**
* The ProviderType of the identity
*/
@Column()
providerType: string;
/**
* The name of the provider.
* Only set if there are multiple provider of that type (e.g. gitlab)
*/
@Column({
nullable: true,
type: 'text',
})
providerName: string | null;
/**
* If the identity should be used as the sync source.
* See [authentication doc](../../docs/content/dev/authentication.md) for clarification
*/
@Column()
syncSource: boolean;
/**
* When the identity was created.
*/
@CreateDateColumn()
createdAt: Date;
/**
* When the identity was last updated.
*/
@UpdateDateColumn()
updatedAt: Date;
/**
* The unique identifier of a user from the login provider
*/
@Column({
nullable: true,
type: 'text',
})
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
*/
@Column({
nullable: true,
type: 'text',
})
passwordHash: string | null;
public static create(
user: User,
providerType: ProviderType,
syncSource = false,
): Identity {
const newIdentity = new Identity();
newIdentity.user = user;
newIdentity.providerType = providerType;
newIdentity.syncSource = syncSource;
return newIdentity;
}
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2021 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';
import { User } from '../users/user.entity';
import { UsersModule } from '../users/users.module';
import { Identity } from './identity.entity';
import { IdentityService } from './identity.service';
import { LocalStrategy } from './local/local.strategy';
@Module({
imports: [
TypeOrmModule.forFeature([Identity, User]),
UsersModule,
PassportModule,
LoggerModule,
],
controllers: [],
providers: [IdentityService, LocalStrategy],
exports: [IdentityService, LocalStrategy],
})
export class IdentityModule {}

View file

@ -0,0 +1,120 @@
/*
* SPDX-FileCopyrightText: 2021 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 { NotInDBError } from '../errors/errors';
import { LoggerModule } from '../logger/logger.module';
import { User } from '../users/user.entity';
import { checkPassword, hashPassword } from '../utils/password';
import { Identity } from './identity.entity';
import { IdentityService } from './identity.service';
import { ProviderType } from './provider-type.enum';
describe('IdentityService', () => {
let service: IdentityService;
let user: User;
let identityRepo: Repository<Identity>;
const password = 'test123';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
IdentityService,
{
provide: getRepositoryToken(Identity),
useClass: Repository,
},
],
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfigMock, authConfigMock],
}),
LoggerModule,
],
}).compile();
service = module.get<IdentityService>(IdentityService);
user = User.create('test', 'Testy') as User;
identityRepo = module.get<Repository<Identity>>(
getRepositoryToken(Identity),
);
});
describe('createLocalIdentity', () => {
it('works', async () => {
jest
.spyOn(identityRepo, 'save')
.mockImplementationOnce(
async (identity: Identity): Promise<Identity> => identity,
);
const identity = await service.createLocalIdentity(user, password);
await checkPassword(password, identity.passwordHash ?? '').then(
(result) => expect(result).toBeTruthy(),
);
expect(identity.user).toEqual(user);
});
});
describe('updateLocalPassword', () => {
beforeEach(async () => {
jest
.spyOn(identityRepo, 'save')
.mockImplementationOnce(
async (identity: Identity): Promise<Identity> => identity,
)
.mockImplementationOnce(
async (identity: Identity): Promise<Identity> => identity,
);
const identity = await service.createLocalIdentity(user, password);
user.identities = Promise.resolve([identity]);
});
it('works', async () => {
const newPassword = 'newPassword';
const identity = await service.updateLocalPassword(user, newPassword);
await checkPassword(newPassword, identity.passwordHash ?? '').then(
(result) => expect(result).toBeTruthy(),
);
expect(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(
NotInDBError,
);
});
});
describe('loginWithLocalIdentity', () => {
it('works', async () => {
const identity = Identity.create(user, ProviderType.LOCAL);
identity.passwordHash = await hashPassword(password);
user.identities = Promise.resolve([identity]);
await expect(
service.loginWithLocalIdentity(user, password),
).resolves.toEqual(undefined);
});
describe('fails', () => {
it('when user has no local identity', async () => {
user.identities = Promise.resolve([]);
await expect(
service.updateLocalPassword(user, password),
).rejects.toThrow(NotInDBError);
});
it('when the password is wrong', async () => {
user.identities = Promise.resolve([]);
await expect(
service.updateLocalPassword(user, 'wrong_password'),
).rejects.toThrow(NotInDBError);
});
});
});
});

View file

@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2021 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 { Repository } from 'typeorm';
import authConfiguration, { AuthConfig } from '../config/auth.config';
import { NotInDBError } 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 { ProviderType } from './provider-type.enum';
import { getFirstIdentityFromUser } from './utils';
@Injectable()
export class IdentityService {
constructor(
private readonly logger: ConsoleLoggerService,
@InjectRepository(Identity)
private identityRepository: Repository<Identity>,
@Inject(authConfiguration.KEY)
private authConfig: AuthConfig,
) {
this.logger.setContext(IdentityService.name);
}
/**
* @async
* Create a new identity for internal auth
* @param {User} user - the user the identity should be added to
* @param {string} password - the password the identity should have
* @return {Identity} the new local identity
*/
async createLocalIdentity(user: User, password: string): Promise<Identity> {
const identity = Identity.create(user, ProviderType.LOCAL);
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 {NotInDBError} the specified user has no internal identity
* @return {Identity} the changed identity
*/
async updateLocalPassword(
user: User,
newPassword: string,
): Promise<Identity> {
const internalIdentity: Identity | undefined =
await 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 NotInDBError('This user has no internal identity.');
}
internalIdentity.passwordHash = await hashPassword(newPassword);
return await this.identityRepository.save(internalIdentity);
}
/**
* @async
* Login the user with their username and password
* @param {User} user - the user to use
* @param {string} password - the password to use
* @throws {NotInDBError} the specified user can't be logged in
*/
async loginWithLocalIdentity(user: User, password: string): Promise<void> {
const internalIdentity: Identity | undefined =
await getFirstIdentityFromUser(user, ProviderType.LOCAL);
if (internalIdentity === undefined) {
this.logger.debug(
`The user with the username ${user.userName} does not have a internal identity.`,
'loginWithLocalIdentity',
);
throw new NotInDBError();
}
if (!(await checkPassword(password, internalIdentity.passwordHash ?? ''))) {
this.logger.debug(
`Password check for ${user.userName} did not succeed.`,
'loginWithLocalIdentity',
);
throw new NotInDBError();
}
}
}

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2021 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 { NotInDBError } from '../../errors/errors';
import { UserRelationEnum } from '../../users/user-relation.enum';
import { User } from '../../users/user.entity';
import { UsersService } from '../../users/users.service';
import { IdentityService } from '../identity.service';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
constructor(
private userService: UsersService,
private identityService: IdentityService,
) {
super();
}
async validate(username: string, password: string): Promise<User> {
try {
const user = await this.userService.getUserByUsername(username, [
UserRelationEnum.IDENTITIES,
]);
await this.identityService.loginWithLocalIdentity(user, password);
return user;
} catch (e) {
if (e instanceof NotInDBError) {
throw new UnauthorizedException(
'This username and password combination did not work.',
);
}
throw e;
}
}
}

View file

@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { IsString } from 'class-validator';
export class LoginDto {
@IsString()
username: string;
@IsString()
password: string;
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { IsString } from 'class-validator';
export class RegisterDto {
@IsString()
username: string;
@IsString()
displayname: string;
@IsString()
password: string;
}

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { IsString } from 'class-validator';
export class UpdatePasswordDto {
@IsString()
newPassword: string;
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum ProviderType {
LOCAL = 'local',
LDAP = 'ldap',
SAML = 'saml',
OAUTH2 = 'oauth2',
GITLAB = 'gitlab',
GITHUB = 'github',
FACEBOOK = 'facebook',
TWITTER = 'twitter',
DROPBOX = 'dropbox',
GOOGLE = 'google',
}

View file

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { User } from '../users/user.entity';
import { UsersService } from '../users/users.service';
@Injectable()
export class SessionGuard implements CanActivate {
constructor(
private readonly logger: ConsoleLoggerService,
private userService: UsersService,
) {
this.logger.setContext(SessionGuard.name);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request: Request & { session?: { user: string }; user?: User } =
context.switchToHttp().getRequest();
if (!request.session) {
this.logger.debug('The user has no session.');
throw new UnauthorizedException("You're not logged in");
}
try {
request.user = await this.userService.getUserByUsername(
request.session.user,
);
return true;
} catch (e) {
if (e instanceof NotInDBError) {
this.logger.debug(
`The user '${request.session.user}' does not exist, but has a session.`,
);
throw new UnauthorizedException("You're not logged in");
}
throw e;
}
}
}

27
src/identity/utils.ts Normal file
View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { User } from '../users/user.entity';
import { Identity } from './identity.entity';
import { ProviderType } from './provider-type.enum';
/**
* Get the first identity of a given type from the user
* @param {User} user - the user to get the identity from
* @param {ProviderType} providerType - the type of the identity
* @return {Identity | undefined} the first identity of the user or undefined, if such an identity can not be found
*/
export async function getFirstIdentityFromUser(
user: User,
providerType: ProviderType,
): Promise<Identity | undefined> {
const identities = await user.identities;
if (identities === undefined) {
return undefined;
}
return identities.find(
(aIdentity) => aIdentity.providerType === providerType,
);
}

View file

@ -10,9 +10,11 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { AppConfig } from './config/app.config';
import { AuthConfig } from './config/auth.config';
import { MediaConfig } from './config/media.config';
import { ConsoleLoggerService } from './logger/console-logger.service';
import { BackendType } from './media/backends/backend-type.enum';
import { setupSessionMiddleware } from './utils/session';
import { setupPrivateApiDocs, setupPublicApiDocs } from './utils/swagger';
async function bootstrap(): Promise<void> {
@ -25,9 +27,10 @@ async function bootstrap(): Promise<void> {
app.useLogger(logger);
const configService = app.get(ConfigService);
const appConfig = configService.get<AppConfig>('appConfig');
const authConfig = configService.get<AuthConfig>('authConfig');
const mediaConfig = configService.get<MediaConfig>('mediaConfig');
if (!appConfig || !mediaConfig) {
if (!appConfig || !authConfig || !mediaConfig) {
logger.error('Could not initialize config, aborting.', 'AppBootstrap');
process.exit(1);
}
@ -45,6 +48,8 @@ async function bootstrap(): Promise<void> {
);
}
setupSessionMiddleware(app, authConfig);
app.enableCors({
origin: appConfig.rendererOrigin,
});

View file

@ -15,6 +15,7 @@ import { Author } from '../authors/author.entity';
import mediaConfigMock from '../config/mock/media.config.mock';
import { ClientError, NotInDBError } from '../errors/errors';
import { Group } from '../groups/group.entity';
import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module';
import { Note } from '../notes/note.entity';
import { NotesModule } from '../notes/notes.module';
@ -23,7 +24,6 @@ import { NoteGroupPermission } from '../permissions/note-group-permission.entity
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
import { Edit } from '../revisions/edit.entity';
import { Revision } from '../revisions/revision.entity';
import { Identity } from '../users/identity.entity';
import { Session } from '../users/session.entity';
import { User } from '../users/user.entity';
import { UsersModule } from '../users/users.module';

View file

@ -19,13 +19,13 @@ import {
} from '../errors/errors';
import { Group } from '../groups/group.entity';
import { GroupsModule } from '../groups/groups.module';
import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module';
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
import { Edit } from '../revisions/edit.entity';
import { Revision } from '../revisions/revision.entity';
import { RevisionsModule } from '../revisions/revisions.module';
import { Identity } from '../users/identity.entity';
import { Session } from '../users/session.entity';
import { User } from '../users/user.entity';
import { UsersModule } from '../users/users.module';

View file

@ -11,13 +11,13 @@ import { AuthToken } from '../auth/auth-token.entity';
import { Author } from '../authors/author.entity';
import appConfigMock from '../config/mock/app.config.mock';
import { Group } from '../groups/group.entity';
import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module';
import { Note } from '../notes/note.entity';
import { NotesModule } from '../notes/notes.module';
import { Tag } from '../notes/tag.entity';
import { Edit } from '../revisions/edit.entity';
import { Revision } from '../revisions/revision.entity';
import { Identity } from '../users/identity.entity';
import { Session } from '../users/session.entity';
import { User } from '../users/user.entity';
import { UsersModule } from '../users/users.module';

View file

@ -13,13 +13,13 @@ import { Author } from '../authors/author.entity';
import appConfigMock from '../config/mock/app.config.mock';
import { NotInDBError } from '../errors/errors';
import { Group } from '../groups/group.entity';
import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module';
import { Note } from '../notes/note.entity';
import { NotesModule } from '../notes/notes.module';
import { Tag } from '../notes/tag.entity';
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
import { Identity } from '../users/identity.entity';
import { Session } from '../users/session.entity';
import { User } from '../users/user.entity';
import { Edit } from './edit.entity';

View file

@ -9,6 +9,8 @@ import { AuthToken } from './auth/auth-token.entity';
import { Author } from './authors/author.entity';
import { Group } from './groups/group.entity';
import { HistoryEntry } from './history/history-entry.entity';
import { Identity } from './identity/identity.entity';
import { ProviderType } from './identity/provider-type.enum';
import { MediaUpload } from './media/media-upload.entity';
import { Note } from './notes/note.entity';
import { Tag } from './notes/tag.entity';
@ -16,9 +18,9 @@ import { NoteGroupPermission } from './permissions/note-group-permission.entity'
import { NoteUserPermission } from './permissions/note-user-permission.entity';
import { Edit } from './revisions/edit.entity';
import { Revision } from './revisions/revision.entity';
import { Identity } from './users/identity.entity';
import { Session } from './users/session.entity';
import { User } from './users/user.entity';
import { hashPassword } from './utils/password';
/**
* This function creates and populates a sqlite db for manual testing
@ -47,6 +49,7 @@ createConnection({
dropSchema: true,
})
.then(async (connection) => {
const password = 'test_password';
const users = [];
users.push(User.create('hardcoded', 'Test User 1'));
users.push(User.create('hardcoded_2', 'Test User 2'));
@ -59,6 +62,9 @@ createConnection({
for (let i = 0; i < 3; i++) {
const author = connection.manager.create(Author, Author.create(1));
const user = connection.manager.create(User, users[i]);
const identity = Identity.create(user, ProviderType.LOCAL);
identity.passwordHash = await hashPassword(password);
connection.manager.create(Identity, identity);
author.user = user;
const revision = Revision.create(
'This is a test note',
@ -70,23 +76,48 @@ createConnection({
notes[i].userPermissions = [];
notes[i].groupPermissions = [];
user.ownedNotes = [notes[i]];
await connection.manager.save([notes[i], user, revision, edit, author]);
await connection.manager.save([
notes[i],
user,
revision,
edit,
author,
identity,
]);
}
const foundUser = await connection.manager.findOne(User);
if (!foundUser) {
throw new Error('Could not find freshly seeded user. Aborting.');
const foundUsers = await connection.manager.find(User);
if (!foundUsers) {
throw new Error('Could not find freshly seeded users. Aborting.');
}
const foundNote = await connection.manager.findOne(Note);
if (!foundNote) {
throw new Error('Could not find freshly seeded note. Aborting.');
const foundNotes = await connection.manager.find(Note);
if (!foundNotes) {
throw new Error('Could not find freshly seeded notes. Aborting.');
}
if (!foundNote.alias) {
throw new Error('Could not find alias of freshly seeded note. Aborting.');
for (const note of foundNotes) {
if (!note.alias) {
throw new Error(
'Could not find alias of freshly seeded notes. Aborting.',
);
}
}
for (const user of foundUsers) {
console.log(
`Created User '${user.userName}' with password '${password}'`,
);
}
for (const note of foundNotes) {
console.log(`Created Note '${note.alias ?? ''}'`);
}
for (const user of foundUsers) {
for (const note of foundNotes) {
const historyEntry = HistoryEntry.create(user, note);
await connection.manager.save(historyEntry);
console.log(
`Created HistoryEntry for user '${user.userName}' and note '${
note.alias ?? ''
}'`,
);
}
}
const historyEntry = HistoryEntry.create(foundUser, foundNote);
await connection.manager.save(historyEntry);
console.log(`Created User '${foundUser.userName}'`);
console.log(`Created Note '${foundNote.alias}'`);
console.log(`Created HistoryEntry`);
})
.catch((error) => console.log(error));

View file

@ -1,56 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity()
export class Identity {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne((_) => User, (user) => user.identities, {
onDelete: 'CASCADE', // This deletes the Identity, when the associated User is deleted
})
user: User;
@Column()
providerName: string;
@Column()
syncSource: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@Column({
nullable: true,
type: 'text',
})
providerUserId: string | null;
@Column({
nullable: true,
type: 'text',
})
oAuthAccessToken: string | null;
@Column({
nullable: true,
type: 'text',
})
passwordHash: string | null;
}

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum UserRelationEnum {
AUTHTOKENS = 'authTokens',
IDENTITIES = 'identities',
}

View file

@ -17,9 +17,9 @@ import { AuthToken } from '../auth/auth-token.entity';
import { Author } from '../authors/author.entity';
import { Group } from '../groups/group.entity';
import { HistoryEntry } from '../history/history-entry.entity';
import { Identity } from '../identity/identity.entity';
import { MediaUpload } from '../media/media-upload.entity';
import { Note } from '../notes/note.entity';
import { Identity } from './identity.entity';
@Entity()
export class User {
@ -59,7 +59,7 @@ export class User {
authTokens: AuthToken[];
@OneToMany((_) => Identity, (identity) => identity.user)
identities: Identity[];
identities: Promise<Identity[]>;
@ManyToMany((_) => Group, (group) => group.members)
groups: Group[];

View file

@ -6,8 +6,8 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Identity } from '../identity/identity.entity';
import { LoggerModule } from '../logger/logger.module';
import { Identity } from './identity.entity';
import { Session } from './session.entity';
import { User } from './user.entity';
import { UsersService } from './users.service';

View file

@ -10,6 +10,7 @@ import { Repository } from 'typeorm';
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { UserInfoDto } from './user-info.dto';
import { UserRelationEnum } from './user-relation.enum';
import { User } from './user.entity';
@Injectable()
@ -73,17 +74,16 @@ export class UsersService {
* @async
* Get the user specified by the username
* @param {string} userName the username by which the user is specified
* @param {boolean} [withTokens=false] if the returned user object should contain authTokens
* @param {UserRelationEnum[]} [withRelations=[]] if the returned user object should contain certain relations
* @return {User} the specified user
*/
async getUserByUsername(userName: string, withTokens = false): Promise<User> {
const relations: string[] = [];
if (withTokens) {
relations.push('authTokens');
}
async getUserByUsername(
userName: string,
withRelations: UserRelationEnum[] = [],
): Promise<User> {
const user = await this.userRepository.findOne({
where: { userName: userName },
relations: relations,
relations: withRelations,
});
if (user === undefined) {
throw new NotInDBError(`User with username '${userName}' not found`);

View file

@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
import { bufferToBase64Url, checkPassword, hashPassword } from './password';
const testPassword = 'thisIsATestPassword';
describe('hashPassword', () => {
it('output looks like a bcrypt hash with 2^12 rounds of hashing', async () => {
/*
* a bcrypt hash example with the different parts highlighted:
* $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
* \__/\/ \____________________/\_____________________________/
* Alg Cost Salt Hash
* from https://en.wikipedia.org/wiki/Bcrypt#Description
*/
const regexBcrypt = /^\$2[abxy]\$12\$[A-Za-z0-9/.]{53}$/;
const hash = await hashPassword(testPassword);
expect(regexBcrypt.test(hash)).toBeTruthy();
});
it('calls bcrypt.hash with the correct parameters', async () => {
const spy = jest.spyOn(bcrypt, 'hash');
await hashPassword(testPassword);
expect(spy).toHaveBeenCalledWith(testPassword, 12);
});
});
describe('checkPassword', () => {
it("is returning true if the inputs are a plaintext password and it's bcrypt-hashed version", async () => {
const hashOfTestPassword =
'$2a$12$WHKCq4c0rg19zyx5WgX0p.or0rjSKYpIBcHhQQGLrxrr6FfMPylIW';
await checkPassword(testPassword, hashOfTestPassword).then((result) =>
expect(result).toBeTruthy(),
);
});
it('fails, if secret is too short', async () => {
const secret = bufferToBase64Url(randomBytes(54));
const hash = await hashPassword(secret);
await checkPassword(secret, hash).then((result) =>
expect(result).toBeTruthy(),
);
await checkPassword(secret.substr(0, secret.length - 1), hash).then(
(result) => expect(result).toBeFalsy(),
);
});
});
describe('bufferToBase64Url', () => {
it('transforms a buffer to the correct base64url encoded string', () => {
expect(
bufferToBase64Url(Buffer.from('testsentence is a test sentence')),
).toEqual('dGVzdHNlbnRlbmNlIGlzIGEgdGVzdCBzZW50ZW5jZQ');
});
});

30
src/utils/password.ts Normal file
View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { compare, hash } from 'bcrypt';
export async function hashPassword(cleartext: string): Promise<string> {
// hash the password with bcrypt and 2^12 iterations
// this was decided on the basis of https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#bcrypt
return await hash(cleartext, 12);
}
export async function checkPassword(
cleartext: string,
password: string,
): Promise<boolean> {
return await compare(cleartext, password);
}
export function bufferToBase64Url(text: Buffer): string {
// This is necessary as the is no base64url encoding in the toString method
// but as can be seen on https://tools.ietf.org/html/rfc4648#page-7
// base64url is quite easy buildable from base64
return text
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

39
src/utils/session.ts Normal file
View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { INestApplication } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { TypeormStore } from 'connect-typeorm';
import session from 'express-session';
import { Repository } from 'typeorm';
import { AuthConfig } from '../config/auth.config';
import { Session } from '../users/session.entity';
/**
* Setup the session middleware via the given authConfig.
* @param {INestApplication} app - the nest application to configure the middleware for.
* @param {AuthConfig} authConfig - the authConfig to configure the middleware with.
*/
export function setupSessionMiddleware(
app: INestApplication,
authConfig: AuthConfig,
): void {
app.use(
session({
name: 'hedgedoc-session',
secret: authConfig.session.secret,
cookie: {
maxAge: authConfig.session.lifetime,
},
resave: false,
saveUninitialized: false,
store: new TypeormStore({
cleanupLimit: 2,
ttl: 86400,
}).connect(app.get<Repository<Session>>(getRepositoryToken(Session))),
}),
);
}

View file

@ -0,0 +1,265 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access
*/
import { INestApplication } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import request from 'supertest';
import { PrivateApiModule } from '../../src/api/private/private-api.module';
import { AuthModule } from '../../src/auth/auth.module';
import { AuthConfig } from '../../src/config/auth.config';
import appConfigMock from '../../src/config/mock/app.config.mock';
import authConfigMock from '../../src/config/mock/auth.config.mock';
import customizationConfigMock from '../../src/config/mock/customization.config.mock';
import externalServicesConfigMock from '../../src/config/mock/external-services.config.mock';
import mediaConfigMock from '../../src/config/mock/media.config.mock';
import { GroupsModule } from '../../src/groups/groups.module';
import { HistoryModule } from '../../src/history/history.module';
import { LoginDto } from '../../src/identity/local/login.dto';
import { RegisterDto } from '../../src/identity/local/register.dto';
import { UpdatePasswordDto } from '../../src/identity/local/update-password.dto';
import { LoggerModule } from '../../src/logger/logger.module';
import { MediaModule } from '../../src/media/media.module';
import { NotesModule } from '../../src/notes/notes.module';
import { PermissionsModule } from '../../src/permissions/permissions.module';
import { UserRelationEnum } from '../../src/users/user-relation.enum';
import { UsersModule } from '../../src/users/users.module';
import { UsersService } from '../../src/users/users.service';
import { checkPassword } from '../../src/utils/password';
import { setupSessionMiddleware } from '../../src/utils/session';
describe('Auth', () => {
let app: INestApplication;
let userService: UsersService;
let username: string;
let displayname: string;
let password: string;
let config: ConfigService;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [
appConfigMock,
authConfigMock,
mediaConfigMock,
customizationConfigMock,
externalServicesConfigMock,
],
}),
PrivateApiModule,
NotesModule,
PermissionsModule,
GroupsModule,
TypeOrmModule.forRoot({
type: 'sqlite',
database: './hedgedoc-e2e-private-auth.sqlite',
autoLoadEntities: true,
synchronize: true,
dropSchema: true,
}),
LoggerModule,
AuthModule,
UsersModule,
MediaModule,
HistoryModule,
],
}).compile();
config = moduleRef.get<ConfigService>(ConfigService);
app = moduleRef.createNestApplication();
const authConfig = config.get('authConfig') as AuthConfig;
setupSessionMiddleware(app, authConfig);
await app.init();
userService = moduleRef.get(UsersService);
username = 'hardcoded';
displayname = 'Testy';
password = 'test_password';
});
describe('POST /auth/local', () => {
it('works', async () => {
const registrationDto: RegisterDto = {
displayname: displayname,
password: password,
username: username,
};
await request(app.getHttpServer())
.post('/auth/local')
.set('Content-Type', 'application/json')
.send(JSON.stringify(registrationDto))
.expect(201);
const newUser = await userService.getUserByUsername(username, [
UserRelationEnum.IDENTITIES,
]);
expect(newUser.displayName).toEqual(displayname);
await expect(newUser.identities).resolves.toHaveLength(1);
await expect(
checkPassword(
password,
(await newUser.identities)[0].passwordHash ?? '',
),
).resolves.toBeTruthy();
});
describe('fails', () => {
it('when the user already exits', async () => {
const username2 = 'already_existing';
await userService.createUser(username2, displayname);
const registrationDto: RegisterDto = {
displayname: displayname,
password: password,
username: username2,
};
await request(app.getHttpServer())
.post('/auth/local')
.set('Content-Type', 'application/json')
.send(JSON.stringify(registrationDto))
.expect(400);
});
it('when registration is disabled', async () => {
config.get('authConfig').local.enableRegister = false;
const registrationDto: RegisterDto = {
displayname: displayname,
password: password,
username: username,
};
await request(app.getHttpServer())
.post('/auth/local')
.set('Content-Type', 'application/json')
.send(JSON.stringify(registrationDto))
.expect(400);
config.get('authConfig').local.enableRegister = true;
});
});
});
describe('PUT /auth/local', () => {
const newPassword = 'new_password';
let cookie = '';
beforeEach(async () => {
const loginDto: LoginDto = {
password: password,
username: username,
};
const response = await request(app.getHttpServer())
.post('/auth/local/login')
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginDto))
.expect(201);
cookie = response.get('Set-Cookie')[0];
});
it('works', async () => {
// Change password
const changePasswordDto: UpdatePasswordDto = {
newPassword: newPassword,
};
await request(app.getHttpServer())
.put('/auth/local')
.set('Content-Type', 'application/json')
.set('Cookie', cookie)
.send(JSON.stringify(changePasswordDto))
.expect(200);
// Successfully login with new password
const loginDto: LoginDto = {
password: newPassword,
username: username,
};
const response = await request(app.getHttpServer())
.post('/auth/local/login')
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginDto))
.expect(201);
cookie = response.get('Set-Cookie')[0];
// Reset password
const changePasswordBackDto: UpdatePasswordDto = {
newPassword: password,
};
await request(app.getHttpServer())
.put('/auth/local')
.set('Content-Type', 'application/json')
.set('Cookie', cookie)
.send(JSON.stringify(changePasswordBackDto))
.expect(200);
});
it('fails, when registration is disabled', async () => {
config.get('authConfig').local.enableLogin = false;
// Try to change password
const changePasswordDto: UpdatePasswordDto = {
newPassword: newPassword,
};
await request(app.getHttpServer())
.put('/auth/local')
.set('Content-Type', 'application/json')
.set('Cookie', cookie)
.send(JSON.stringify(changePasswordDto))
.expect(400);
// enable login again
config.get('authConfig').local.enableLogin = true;
// new password doesn't work for login
const loginNewPasswordDto: LoginDto = {
password: newPassword,
username: username,
};
await request(app.getHttpServer())
.post('/auth/local/login')
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginNewPasswordDto))
.expect(401);
// old password does work for login
const loginOldPasswordDto: LoginDto = {
password: password,
username: username,
};
await request(app.getHttpServer())
.post('/auth/local/login')
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginOldPasswordDto))
.expect(201);
});
});
describe('POST /auth/local/login', () => {
it('works', async () => {
config.get('authConfig').local.enableLogin = true;
const loginDto: LoginDto = {
password: password,
username: username,
};
await request(app.getHttpServer())
.post('/auth/local/login')
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginDto))
.expect(201);
});
});
describe('DELETE /auth/logout', () => {
it('works', async () => {
config.get('authConfig').local.enableLogin = true;
const loginDto: LoginDto = {
password: password,
username: username,
};
const response = await request(app.getHttpServer())
.post('/auth/local/login')
.set('Content-Type', 'application/json')
.send(JSON.stringify(loginDto))
.expect(201);
const cookie = response.get('Set-Cookie')[0];
await request(app.getHttpServer())
.delete('/auth/logout')
.set('Cookie', cookie)
.expect(200);
});
});
});

View file

@ -1147,7 +1147,7 @@
"@types/qs" "*"
"@types/range-parser" "*"
"@types/express-session@^1.15.5":
"@types/express-session@^1.15.5", "@types/express-session@^1.17.4":
version "1.17.4"
resolved "https://registry.yarnpkg.com/@types/express-session/-/express-session-1.17.4.tgz#97a30a35e853a61bdd26e727453b8ed314d6166b"
integrity sha512-7cNlSI8+oOBUHTfPXMwDxF/Lchx5aJ3ho7+p9jJZYVg9dVDJFh3qdMXmJtRsysnvS+C6x46k9DRYmrmCkE+MVg==
@ -1293,6 +1293,23 @@
"@types/koa" "*"
"@types/passport" "*"
"@types/passport-local@^1.0.34":
version "1.0.34"
resolved "https://registry.yarnpkg.com/@types/passport-local/-/passport-local-1.0.34.tgz#84d3b35b2fd4d36295039ded17fe5f3eaa62f4f6"
integrity sha512-PSc07UdYx+jhadySxxIYWuv6sAnY5e+gesn/5lkPKfBeGuIYn9OPR+AAEDq73VRUh6NBTpvE/iPE62rzZUslog==
dependencies:
"@types/express" "*"
"@types/passport" "*"
"@types/passport-strategy" "*"
"@types/passport-strategy@*":
version "0.2.35"
resolved "https://registry.yarnpkg.com/@types/passport-strategy/-/passport-strategy-0.2.35.tgz#e52f5212279ea73f02d9b06af67efe9cefce2d0c"
integrity sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==
dependencies:
"@types/express" "*"
"@types/passport" "*"
"@types/passport@*":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.7.tgz#85892f14932168158c86aecafd06b12f5439467a"
@ -3158,7 +3175,7 @@ expect@^27.2.0:
jest-message-util "^27.2.0"
jest-regex-util "^27.0.6"
express-session@^1.15.6:
express-session@1.17.2, express-session@^1.15.6:
version "1.17.2"
resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.2.tgz#397020374f9bf7997f891b85ea338767b30d0efd"
integrity sha512-mPcYcLA0lvh7D4Oqr5aNJFMtBMKPLl++OKKxkHzZ0U0oDq1rpKBnkR5f5vCHR26VeArlTOEF9td4x5IjICksRQ==
@ -5573,6 +5590,13 @@ passport-http-bearer@1.0.1:
dependencies:
passport-strategy "1.x.x"
passport-local@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee"
integrity sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=
dependencies:
passport-strategy "1.x.x"
passport-strategy@1.x.x:
version "1.0.0"
resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"