mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-23 02:06:29 -05:00
Merge pull request #1517 from hedgedoc/privateApi/auth/email
Add local login with username and password
This commit is contained in:
commit
83f0bbb986
51 changed files with 1352 additions and 178 deletions
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
71
src/api/private/auth/auth.controller.spec.ts
Normal file
71
src/api/private/auth/auth.controller.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
105
src/api/private/auth/auth.controller.ts
Normal file
105
src/api/private/auth/auth.controller.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
33
src/api/utils/login-enabled.guard.ts
Normal file
33
src/api/utils/login-enabled.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
33
src/api/utils/registration-enabled.guard.ts
Normal file
33
src/api/utils/registration-enabled.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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],
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
111
src/identity/identity.entity.ts
Normal file
111
src/identity/identity.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
28
src/identity/identity.module.ts
Normal file
28
src/identity/identity.module.ts
Normal 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 {}
|
120
src/identity/identity.service.spec.ts
Normal file
120
src/identity/identity.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
94
src/identity/identity.service.ts
Normal file
94
src/identity/identity.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
44
src/identity/local/local.strategy.ts
Normal file
44
src/identity/local/local.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
13
src/identity/local/login.dto.ts
Normal file
13
src/identity/local/login.dto.ts
Normal 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;
|
||||
}
|
17
src/identity/local/register.dto.ts
Normal file
17
src/identity/local/register.dto.ts
Normal 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;
|
||||
}
|
11
src/identity/local/update-password.dto.ts
Normal file
11
src/identity/local/update-password.dto.ts
Normal 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;
|
||||
}
|
18
src/identity/provider-type.enum.ts
Normal file
18
src/identity/provider-type.enum.ts
Normal 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',
|
||||
}
|
49
src/identity/session.guard.ts
Normal file
49
src/identity/session.guard.ts
Normal 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
27
src/identity/utils.ts
Normal 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,
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
61
src/seed.ts
61
src/seed.ts
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
}
|
10
src/users/user-relation.enum.ts
Normal file
10
src/users/user-relation.enum.ts
Normal 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',
|
||||
}
|
|
@ -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[];
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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`);
|
||||
|
|
59
src/utils/password.spec.ts
Normal file
59
src/utils/password.spec.ts
Normal 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
30
src/utils/password.ts
Normal 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
39
src/utils/session.ts
Normal 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))),
|
||||
}),
|
||||
);
|
||||
}
|
265
test/private-api/auth.e2e-spec.ts
Normal file
265
test/private-api/auth.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
28
yarn.lock
28
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue