feat(backend): handle username always in lowercase

This should make all usernames of new users into lowercase. Usernames are also searched in the DB as lowercase.

Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Philip Molares 2023-05-13 14:56:42 +02:00 committed by Tilman Vatteroth
parent 9625900d1c
commit 0a8945d934
23 changed files with 99 additions and 58 deletions

View file

@ -29,6 +29,7 @@ import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { SessionState } from '../../../session/session.service';
import { User } from '../../../users/user.entity';
import { UsersService } from '../../../users/users.service';
import { makeUsernameLowercase } from '../../../utils/username';
import { LoginEnabledGuard } from '../../utils/login-enabled.guard';
import { OpenApi } from '../../utils/openapi.decorator';
import { RegistrationEnabledGuard } from '../../utils/registration-enabled.guard';
@ -107,8 +108,8 @@ export class AuthController {
@Param('ldapIdentifier') ldapIdentifier: string,
@Body() loginDto: LdapLoginDto,
): void {
// There is no further testing needed as we only get to this point if LocalAuthGuard was successful
request.session.username = loginDto.username;
// There is no further testing needed as we only get to this point if LdapAuthGuard was successful
request.session.username = makeUsernameLowercase(loginDto.username);
request.session.authProvider = 'ldap';
}

View file

@ -39,6 +39,7 @@ import { RevisionDto } from '../../../revisions/revision.dto';
import { RevisionsService } from '../../../revisions/revisions.service';
import { User } from '../../../users/user.entity';
import { UsersService } from '../../../users/users.service';
import { Username } from '../../../utils/username';
import { GetNoteInterceptor } from '../../utils/get-note.interceptor';
import { MarkdownBody } from '../../utils/markdown-body.decorator';
import { OpenApi } from '../../utils/openapi.decorator';
@ -203,7 +204,7 @@ export class NotesController {
async setUserPermission(
@RequestUser() user: User,
@RequestNote() note: Note,
@Param('userName') username: string,
@Param('userName') username: Username,
@Body('canEdit') canEdit: boolean,
): Promise<NotePermissionsDto> {
const permissionUser = await this.userService.getUserByUsername(username);
@ -221,7 +222,7 @@ export class NotesController {
async removeUserPermission(
@RequestUser() user: User,
@RequestNote() note: Note,
@Param('userName') username: string,
@Param('userName') username: Username,
): Promise<NotePermissionsDto> {
try {
const permissionUser = await this.userService.getUserByUsername(username);
@ -281,7 +282,7 @@ export class NotesController {
async changeOwner(
@RequestUser() user: User,
@RequestNote() note: Note,
@Body('newOwner') newOwner: string,
@Body('newOwner') newOwner: Username,
): Promise<NoteDto> {
const owner = await this.userService.getUserByUsername(newOwner);
return await this.noteService.toNoteDto(

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -9,6 +9,7 @@ import { ApiTags } from '@nestjs/swagger';
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { UserInfoDto } from '../../../users/user-info.dto';
import { UsersService } from '../../../users/users.service';
import { Username } from '../../../utils/username';
import { OpenApi } from '../../utils/openapi.decorator';
@ApiTags('users')
@ -23,7 +24,7 @@ export class UsersController {
@Get(':username')
@OpenApi(200)
async getUser(@Param('username') username: string): Promise<UserInfoDto> {
async getUser(@Param('username') username: Username): Promise<UserInfoDto> {
return this.userService.toUserDto(
await this.userService.getUserByUsername(username),
);

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -42,6 +42,7 @@ import { RevisionDto } from '../../../revisions/revision.dto';
import { RevisionsService } from '../../../revisions/revisions.service';
import { User } from '../../../users/user.entity';
import { UsersService } from '../../../users/users.service';
import { Username } from '../../../utils/username';
import { GetNoteInterceptor } from '../../utils/get-note.interceptor';
import { MarkdownBody } from '../../utils/markdown-body.decorator';
import { OpenApi } from '../../utils/openapi.decorator';
@ -264,7 +265,7 @@ export class NotesController {
async setUserPermission(
@RequestUser() user: User,
@RequestNote() note: Note,
@Param('userName') username: string,
@Param('userName') username: Username,
@Body('canEdit') canEdit: boolean,
): Promise<NotePermissionsDto> {
const permissionUser = await this.userService.getUserByUsername(username);
@ -291,7 +292,7 @@ export class NotesController {
async removeUserPermission(
@RequestUser() user: User,
@RequestNote() note: Note,
@Param('userName') username: string,
@Param('userName') username: Username,
): Promise<NotePermissionsDto> {
try {
const permissionUser = await this.userService.getUserByUsername(username);
@ -377,7 +378,7 @@ export class NotesController {
async changeOwner(
@RequestUser() user: User,
@RequestNote() note: Note,
@Body('newOwner') newOwner: string,
@Body('newOwner') newOwner: Username,
): Promise<NoteDto> {
const owner = await this.userService.getUserByUsername(newOwner);
return await this.noteService.toNoteDto(

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -7,7 +7,7 @@ import { IsString } from 'class-validator';
export class LdapLoginDto {
@IsString()
username: string;
username: string; // This is not of type Username, because LDAP server may use mixed case usernames
@IsString()
password: string;
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -22,6 +22,7 @@ import authConfiguration, {
import { NotInDBError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { UsersService } from '../../users/users.service';
import { makeUsernameLowercase } from '../../utils/username';
import { Identity } from '../identity.entity';
import { IdentityService } from '../identity.service';
import { ProviderType } from '../provider-type.enum';
@ -85,7 +86,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') {
*/
private loginWithLDAP(
ldapConfig: LDAPConfig,
username: string,
username: string, // This is not of type Username, because LDAP server may use mixed case usernames
password: string,
doneCallBack: VerifiedCallback,
): void {
@ -146,7 +147,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') {
userId: string,
ldapConfig: LDAPConfig,
user: Record<string, string>,
username: string,
username: string, // This is not of type Username, because LDAP server may use mixed case usernames
): void {
this.identityService
.getIdentityFromUserIdAndProviderType(userId, ProviderType.LDAP)
@ -162,8 +163,9 @@ export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') {
.catch(async (error) => {
if (error instanceof NotInDBError) {
// The user/identity does not yet exist
const usernameLowercase = makeUsernameLowercase(username); // This ensures ldap user can be given permission via usernames
const newUser = await this.usersService.createUser(
username,
usernameLowercase,
// if there is no displayName we use the username
user[ldapConfig.displayNameField] ?? username,
);

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -15,6 +15,7 @@ import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { UserRelationEnum } from '../../users/user-relation.enum';
import { User } from '../../users/user.entity';
import { UsersService } from '../../users/users.service';
import { Username } from '../../utils/username';
import { IdentityService } from '../identity.service';
@Injectable()
@ -31,7 +32,7 @@ export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
logger.setContext(LocalStrategy.name);
}
async validate(username: string, password: string): Promise<User> {
async validate(username: Username, password: string): Promise<User> {
try {
const user = await this.userService.getUserByUsername(username, [
UserRelationEnum.IDENTITIES,

View file

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

View file

@ -1,13 +1,16 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { IsString } from 'class-validator';
import { IsLowercase, IsString } from 'class-validator';
import { Username } from '../../utils/username';
export class RegisterDto {
@IsString()
username: string;
@IsLowercase()
username: Username;
@IsString()
displayName: string;

View file

@ -1,13 +1,14 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDate, IsOptional, IsString } from 'class-validator';
import { IsDate, IsLowercase, IsOptional, IsString } from 'class-validator';
import { BaseDto } from '../utils/base.dto.';
import { Username } from '../utils/username';
export class MediaUploadDto extends BaseDto {
/**
@ -41,6 +42,7 @@ export class MediaUploadDto extends BaseDto {
* @example "testuser5"
*/
@IsString()
@IsLowercase()
@ApiProperty()
username: string | null;
username: Username | null;
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -8,20 +8,23 @@ import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsLowercase,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import { BaseDto } from '../utils/base.dto.';
import { Username } from '../utils/username';
export class NoteUserPermissionEntryDto extends BaseDto {
/**
* Username of the User this permission applies to
*/
@IsString()
@IsLowercase()
@ApiProperty()
username: string;
username: Username;
/**
* True if the user is allowed to edit the note
@ -38,8 +41,9 @@ export class NoteUserPermissionUpdateDto {
* @example "john.smith"
*/
@IsString()
@IsLowercase()
@ApiProperty()
username: string;
username: Username;
/**
* True if the user should be allowed to edit the note

View file

@ -13,6 +13,7 @@ import { Mock } from 'ts-mockery';
import { Note } from '../../notes/note.entity';
import { User } from '../../users/user.entity';
import { Username } from '../../utils/username';
import * as NameRandomizerModule from './random-word-lists/name-randomizer';
import { RealtimeConnection } from './realtime-connection';
import { RealtimeNote } from './realtime-note';
@ -39,7 +40,7 @@ describe('websocket connection', () => {
let mockedUser: User;
let mockedMessageTransporter: MessageTransporter;
const mockedUserName = 'mockedUserName';
const mockedUserName: Username = 'mocked-user-name';
const mockedDisplayName = 'mockedDisplayName';
beforeEach(() => {

View file

@ -40,9 +40,9 @@ describe('RealtimeNoteService', () => {
let clientWithoutReadWrite: RealtimeConnection;
let deleteIntervalSpy: jest.SpyInstance;
const readWriteUsername = 'canReadWriteUser';
const onlyReadUsername = 'canOnlyReadUser';
const noAccessUsername = 'noReadWriteUser';
const readWriteUsername = 'can-read-write-user';
const onlyReadUsername = 'can-only-read-user';
const noAccessUsername = 'no-read-write-user';
afterAll(() => {
jest.useRealTimers();

View file

@ -11,6 +11,8 @@ import {
} from '@hedgedoc/commons';
import { Listener } from 'eventemitter2';
import { Username } from '../../utils/username';
export type OtherAdapterCollector = () => RealtimeUserStatusAdapter[];
/**
@ -20,7 +22,7 @@ export class RealtimeUserStatusAdapter {
private readonly realtimeUser: RealtimeUser;
constructor(
private readonly username: string | null,
private readonly username: Username | null,
private readonly displayName: string,
private collectOtherAdapters: OtherAdapterCollector,
private messageTransporter: MessageTransporter,

View file

@ -11,6 +11,7 @@ import {
import { Mock } from 'ts-mockery';
import { User } from '../../../users/user.entity';
import { Username } from '../../../utils/username';
import { RealtimeConnection } from '../realtime-connection';
import { RealtimeNote } from '../realtime-note';
import { RealtimeUserStatusAdapter } from '../realtime-user-status-adapter';
@ -21,13 +22,13 @@ enum RealtimeUserState {
WITH_READONLY,
}
const MOCK_FALLBACK_USERNAME = 'mock';
const MOCK_FALLBACK_USERNAME: Username = 'mock';
/**
* Creates a mocked {@link RealtimeConnection realtime connection}.
*/
export class MockConnectionBuilder {
private username: string | null;
private username: Username | null;
private displayName: string | undefined;
private includeRealtimeUserStatus: RealtimeUserState =
RealtimeUserState.WITHOUT;
@ -50,7 +51,7 @@ export class MockConnectionBuilder {
*
* @param username the username of the mocked user. If this value is omitted then the builder will user a {@link MOCK_FALLBACK_USERNAME fallback}.
*/
public withLoggedInUser(username?: string): this {
public withLoggedInUser(username?: Username): this {
const newUsername = username ?? MOCK_FALLBACK_USERNAME;
this.username = newUsername;
this.displayName = newUsername;

View file

@ -41,6 +41,7 @@ import { Session } from '../../users/session.entity';
import { User } from '../../users/user.entity';
import { UsersModule } from '../../users/users.module';
import { UsersService } from '../../users/users.service';
import { Username } from '../../utils/username';
import * as websocketConnectionModule from '../realtime-note/realtime-connection';
import { RealtimeConnection } from '../realtime-note/realtime-connection';
import { RealtimeNote } from '../realtime-note/realtime-note';
@ -165,7 +166,7 @@ describe('Websocket gateway', () => {
),
);
const mockUsername = 'mockUsername';
const mockUsername: Username = 'mock-username';
jest
.spyOn(sessionService, 'fetchUsernameForSessionId')
.mockImplementation((sessionId: string) =>

View file

@ -28,7 +28,7 @@ describe('SessionService', () => {
let authConfigMock: AuthConfig;
let typeormStoreConstructorMock: jest.SpyInstance;
const mockedExistingSessionId = 'mockedExistingSessionId';
const mockUsername = 'mockUser';
const mockUsername = 'mock-user';
const mockSecret = 'mockSecret';
let sessionService: SessionService;

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -19,10 +19,11 @@ import databaseConfiguration, {
} from '../config/database.config';
import { Session } from '../users/session.entity';
import { HEDGEDOC_SESSION } from '../utils/session';
import { Username } from '../utils/username';
export interface SessionState {
cookie: unknown;
username?: string;
username?: Username;
authProvider: string;
}
@ -58,10 +59,10 @@ export class SessionService {
* @param sessionId The session id for which the owning user should be found
* @return A Promise that either resolves with the username or rejects with an error
*/
fetchUsernameForSessionId(sessionId: string): Promise<string | undefined> {
fetchUsernameForSessionId(sessionId: string): Promise<Username | undefined> {
return new Promise((resolve, reject) => {
this.typeormStore.get(sessionId, (error?: Error, result?: SessionState) =>
error || !result ? reject(error) : resolve(result.username),
error || !result ? reject(error) : resolve(result.username as Username),
);
});
}

View file

@ -1,12 +1,13 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { IsLowercase, IsString } from 'class-validator';
import { BaseDto } from '../utils/base.dto.';
import { Username } from '../utils/username';
export class UserInfoDto extends BaseDto {
/**
@ -14,8 +15,9 @@ export class UserInfoDto extends BaseDto {
* @example "john.smith"
*/
@IsString()
@IsLowercase()
@ApiProperty()
username: string;
username: Username;
/**
* The display name

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -20,6 +20,7 @@ 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 { Username } from '../utils/username';
@Entity()
export class User {
@ -29,7 +30,7 @@ export class User {
@Column({
unique: true,
})
username: string;
username: Username;
@Column()
displayName: string;
@ -77,7 +78,7 @@ export class User {
private constructor() {}
public static create(
username: string,
username: Username,
displayName: string,
): Omit<User, 'id' | 'createdAt' | 'updatedAt'> {
const newUser = new User();

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -9,6 +9,7 @@ import { Repository } from 'typeorm';
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { Username } from '../utils/username';
import {
FullUserInfoDto,
UserInfoDto,
@ -29,12 +30,12 @@ export class UsersService {
/**
* @async
* Create a new user with a given username and displayName
* @param username - the username the new user shall have
* @param displayName - the display name the new user shall have
* @param {Username} username - the username the new user shall have
* @param {string} displayName - the display name the new user shall have
* @return {User} the user
* @throws {AlreadyInDBError} the username is already taken.
*/
async createUser(username: string, displayName: string): Promise<User> {
async createUser(username: Username, displayName: string): Promise<User> {
const user = User.create(username, displayName);
try {
return await this.userRepository.save(user);
@ -77,12 +78,12 @@ export class UsersService {
/**
* @async
* Get the user specified by the username
* @param {string} username the username by which the user is specified
* @param {Username} username the username by which the user is specified
* @param {UserRelationEnum[]} [withRelations=[]] if the returned user object should contain certain relations
* @return {User} the specified user
*/
async getUserByUsername(
username: string,
username: Username,
withRelations: UserRelationEnum[] = [],
): Promise<User> {
const user = await this.userRepository.findOne({

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export type Username = Lowercase<string>;
export function makeUsernameLowercase(username: string): Username {
return username.toLowerCase() as Username;
}

View file

@ -16,12 +16,13 @@ import { RegisterDto } from '../../src/identity/local/register.dto';
import { UpdatePasswordDto } from '../../src/identity/local/update-password.dto';
import { UserRelationEnum } from '../../src/users/user-relation.enum';
import { checkPassword } from '../../src/utils/password';
import { Username } from '../../src/utils/username';
import { TestSetup, TestSetupBuilder } from '../test-setup';
describe('Auth', () => {
let testSetup: TestSetup;
let username: string;
let username: Username;
let displayName: string;
let password: string;