This commit is contained in:
Bastian Wagner
2026-08-01 13:17:26 +02:00
parent e3bff40181
commit 4c1bd49405
23 changed files with 185 additions and 2027 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -39,15 +39,10 @@
"@nestjs/serve-static": "^3.0.0",
"@nestjs/swagger": "6.1.3",
"@nestjs/typeorm": "9.0.1",
"apple-signin-auth": "1.7.4",
"bcryptjs": "2.4.3",
"class-transformer": "0.5.1",
"class-validator": "0.13.2",
"fb": "2.0.0",
"google-auth-library": "8.7.0",
"handlebars": "4.7.7",
"multer": "1.4.4",
"multer-s3": "2.10.0",
"mysql2": "^2.3.3",
"nodemailer": "6.8.0",
"passport": "0.6.0",
@@ -59,7 +54,6 @@
"rxjs": "7.5.7",
"source-map-support": "0.5.21",
"swagger-ui-express": "4.5.0",
"twitter": "1.7.1",
"typeorm": "0.3.10"
},
"devDependencies": {
@@ -69,17 +63,13 @@
"@nestjs/testing": "9.1.6",
"@types/bcryptjs": "2.4.2",
"@types/express": "4.17.14",
"@types/facebook-js-sdk": "3.3.6",
"@types/jest": "29.2.3",
"@types/multer": "1.4.7",
"@types/node": "16.18.3",
"@types/passport-anonymous": "1.0.3",
"@types/passport-jwt": "3.0.7",
"@types/supertest": "2.0.12",
"@types/twitter": "1.7.1",
"@typescript-eslint/eslint-plugin": "5.43.0",
"@typescript-eslint/parser": "5.43.0",
"aws-sdk": "2.1243.0",
"env-cmd": "10.1.0",
"eslint": "8.27.0",
"eslint-config-prettier": "8.5.0",

View File

@@ -5,7 +5,6 @@ import databaseConfig from './config/database.config';
import authConfig from './config/auth.config';
import appConfig from './config/app.config';
import mailConfig from './config/mail.config';
import fileConfig from './config/file.config';
import { MailerModule } from '@nestjs-modules/mailer';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -29,7 +28,7 @@ import { PenaltyModule } from './penalty/penalty.module';
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [databaseConfig, authConfig, appConfig, mailConfig, fileConfig],
load: [databaseConfig, authConfig, appConfig, mailConfig],
envFilePath: ['.env'],
}),
TypeOrmModule.forRootAsync({

View File

@@ -1,7 +1,3 @@
export enum AuthProvidersEnum {
email = 'email',
facebook = 'facebook',
google = 'google',
twitter = 'twitter',
apple = 'apple',
}

View File

@@ -72,82 +72,6 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
});
});
it('rejects social login when the existing account is inactive', async () => {
const inactive = socialUser(
AuthProvidersEnum.google,
RoleEnum.user,
StatusEnum.inactive,
);
configureSocialQueries([inactive], inactive);
await expect(
service.validateSocialLogin(AuthProvidersEnum.google, {
id: 'social-id',
email: inactive.email,
firstName: 'Inactive',
lastName: 'User',
}),
).rejects.toBeInstanceOf(ForbiddenException);
expect(usersService.update).not.toHaveBeenCalled();
expect(jwtService.sign).not.toHaveBeenCalled();
});
it('reloads and rejects a concurrently deactivated social user instead of signing stale state', async () => {
const stale = socialUser(
AuthProvidersEnum.google,
RoleEnum.admin,
StatusEnum.active,
);
const current = socialUser(
AuthProvidersEnum.google,
RoleEnum.user,
StatusEnum.inactive,
);
configureSocialQueries([stale], current);
await expect(
service.validateSocialLogin(AuthProvidersEnum.google, {
id: stale.socialId,
email: 'updated@example.com',
}),
).rejects.toBeInstanceOf(ForbiddenException);
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(userRepository.save).not.toHaveBeenCalledWith(stale);
expect(usersService.update).not.toHaveBeenCalled();
expect(jwtService.sign).not.toHaveBeenCalled();
});
it.each([
AuthProvidersEnum.facebook,
AuthProvidersEnum.google,
AuthProvidersEnum.twitter,
AuthProvidersEnum.apple,
])(
'locks and reloads an existing %s user, narrowly updates email, and signs the current role',
async (provider) => {
const stale = socialUser(provider, RoleEnum.admin, StatusEnum.active);
const current = socialUser(provider, RoleEnum.user, StatusEnum.active);
configureSocialQueries([stale], current);
const result = await service.validateSocialLogin(provider, {
id: stale.socialId,
email: 'updated@example.com',
});
expect(userRepository.update).toHaveBeenCalledWith(stale.id, {
email: 'updated@example.com',
});
expect(userRepository.save).not.toHaveBeenCalledWith(stale);
expect(jwtService.sign).toHaveBeenCalledWith({
id: current.id,
role: current.role,
});
expect(result.user).toBe(current);
},
);
it('never includes an email in an unknown-user login audit event', async () => {
usersService.findOne.mockResolvedValue(undefined);
@@ -204,11 +128,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
await service.confirmEmail('confirmation-hash');
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(lockedUserQuery.setLock).toHaveBeenCalledWith(
'pessimistic_write',
undefined,
['user'],
);
expect(lockedUserQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(confirmationUser.status).toEqual({ id: StatusEnum.active });
expect(confirmationUser.hash).toBeNull();
expect(userRepository.save).toHaveBeenCalledWith(confirmationUser);
@@ -247,45 +167,4 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
},
};
}
function socialUser(
provider: AuthProvidersEnum,
roleId: RoleEnum,
statusId: StatusEnum,
) {
return {
...user(statusId),
email: 'old@example.com',
socialId: `${provider}-id`,
provider,
role: { id: roleId, name: roleId === RoleEnum.admin ? 'Admin' : 'User' },
hash: 'stale-hash',
};
}
function configureSocialQueries(candidates: any[], current: any) {
const candidateQuery = chain({ getMany: jest.fn(() => candidates) });
const reloadQuery = chain({ getOne: jest.fn(() => current) });
userRepository.createQueryBuilder = jest.fn((alias: string) =>
alias === 'socialCandidate' ? candidateQuery : reloadQuery,
);
userRepository.update = jest.fn();
}
function chain(overrides: Record<string, jest.Mock>) {
const query: Record<string, jest.Mock> = {};
[
'leftJoinAndSelect',
'where',
'orWhere',
'andWhere',
'setParameter',
'setParameters',
'setLock',
'orderBy',
].forEach((method) => {
query[method] = jest.fn(() => query);
});
return Object.assign(query, overrides);
}
});

View File

@@ -18,7 +18,6 @@ import { plainToClass } from 'class-transformer';
import { Status } from 'src/statuses/entities/status.entity';
import { Role } from 'src/roles/entities/role.entity';
import { AuthProvidersEnum } from './auth-providers.enum';
import { SocialInterface } from 'src/social/interfaces/social.interface';
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
import { UsersService } from 'src/users/users.service';
import { ForgotService } from 'src/forgot/forgot.service';
@@ -113,74 +112,6 @@ export class AuthService {
}
}
async validateSocialLogin(
authProvider: string,
socialData: SocialInterface,
): Promise<{ token: string; user: User }> {
const socialEmail = socialData.email?.toLowerCase();
return this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(User);
const candidateQuery = repository
.createQueryBuilder('socialCandidate')
.where(
'socialCandidate.socialId = :socialId AND socialCandidate.provider = :authProvider',
{ socialId: socialData.id, authProvider },
);
if (socialEmail) {
candidateQuery.orWhere('socialCandidate.email = :socialEmail', {
socialEmail,
});
}
const candidates = await candidateQuery
.setLock('pessimistic_write', undefined, ['socialCandidate'])
.orderBy('socialCandidate.id', 'ASC')
.getMany();
const socialUser = candidates.find(
(candidate) =>
candidate.socialId === socialData.id &&
candidate.provider === authProvider,
);
const emailUser = socialEmail
? candidates.find((candidate) => candidate.email === socialEmail)
: undefined;
let user = socialUser ?? emailUser;
if (!user) {
user = await repository.save(
repository.create({
email: socialEmail,
firstName: socialData.firstName,
lastName: socialData.lastName,
socialId: socialData.id,
provider: authProvider,
role: { id: RoleEnum.user } as Role,
status: { id: StatusEnum.active } as Status,
}),
);
} else if (
socialUser &&
socialEmail &&
!emailUser &&
socialUser.email !== socialEmail
) {
await repository.update(socialUser.id, { email: socialEmail });
}
const currentUser = await repository
.createQueryBuilder('currentSocialUser')
.leftJoinAndSelect('currentSocialUser.role', 'role')
.leftJoinAndSelect('currentSocialUser.status', 'status')
.where('currentSocialUser.id = :userId', { userId: user.id })
.setLock('pessimistic_write', undefined, ['currentSocialUser'])
.getOne();
if (!currentUser) throw new UnauthorizedException();
await this.assertActiveUser(currentUser);
const token = await this.jwtService.sign({
id: currentUser.id,
role: currentUser.role,
});
return { token, user: currentUser };
});
}
async register(dto: AuthRegisterLoginDto): Promise<void> {
const hash = crypto
.createHash('sha256')
@@ -222,7 +153,7 @@ export class AuthService {
const user = await repository
.createQueryBuilder('user')
.where('user.hash = :hash', { hash })
.setLock('pessimistic_write', undefined, ['user'])
.setLock('pessimistic_write')
.getOne();
if (!user) {
throw new HttpException(

View File

@@ -1,22 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Allow, IsNotEmpty } from 'class-validator';
import { Tokens } from 'src/social/tokens';
import { AuthProvidersEnum } from '../auth-providers.enum';
export class AuthSocialLoginDto {
@Allow()
@ApiProperty({ type: () => Tokens })
tokens: Tokens;
@ApiProperty({ enum: AuthProvidersEnum })
@IsNotEmpty()
socialType: AuthProvidersEnum;
@Allow()
@ApiProperty({ required: false })
firstName?: string;
@Allow()
@ApiProperty({ required: false })
lastName?: string;
}

View File

@@ -1,11 +0,0 @@
import { registerAs } from '@nestjs/config';
export default registerAs('file', () => ({
driver: process.env.FILE_DRIVER,
accessKeyId: process.env.ACCESS_KEY_ID,
secretAccessKey: process.env.SECRET_ACCESS_KEY,
awsDefaultS3Bucket: process.env.AWS_DEFAULT_S3_BUCKET,
awsDefaultS3Url: process.env.AWS_DEFAULT_S3_URL,
awsS3Region: process.env.AWS_S3_REGION,
maxFileSize: 5242880, // 5mb
}));

View File

@@ -1,15 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { HomeService } from './home.service';
@ApiTags('Home')
@Controller()
export class HomeController {
constructor(private service: HomeService) {}
@Get()
appInfo() {
return this.service.appInfo();
}
}

View File

@@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { HomeService } from './home.service';
import { HomeController } from './home.controller';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule],
controllers: [HomeController],
providers: [HomeService],
})
export class HomeModule {}

View File

@@ -1,11 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class HomeService {
constructor(private configService: ConfigService) {}
appInfo() {
return { name: this.configService.get('app.name') };
}
}

View File

@@ -1,6 +0,0 @@
export interface SocialInterface {
id: string;
firstName?: string;
lastName?: string;
email?: string;
}

View File

@@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Allow, IsNotEmpty } from 'class-validator';
export class Tokens {
@ApiProperty()
@IsNotEmpty()
token1: string;
@Allow()
@ApiProperty()
token2?: string;
}

View File

@@ -94,11 +94,7 @@ describe('AdminUsersService', () => {
},
manager,
);
expect(lockedUserQuery.setLock).toHaveBeenCalledWith(
'pessimistic_write',
undefined,
['lockedUser'],
);
expect(lockedUserQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(lockedUserQuery.leftJoinAndSelect).toHaveBeenCalledTimes(2);
});
@@ -122,11 +118,7 @@ describe('AdminUsersService', () => {
service.updateRole(actorId, target.id, RoleEnum.user),
).rejects.toBeInstanceOf(ConflictException);
expect(lockQuery.setLock).toHaveBeenCalledWith(
'pessimistic_write',
undefined,
['user'],
);
expect(lockQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(userRepository.save).not.toHaveBeenCalled();
});

View File

@@ -269,7 +269,7 @@ export class AdminUsersService {
.andWhere('status.id = :activeStatus', {
activeStatus: StatusEnum.active,
})
.setLock('pessimistic_write', undefined, ['user'])
.setLock('pessimistic_write')
.orderBy('user.id', 'ASC')
.getMany();
}
@@ -283,7 +283,7 @@ export class AdminUsersService {
.leftJoinAndSelect('lockedUser.role', 'role')
.leftJoinAndSelect('lockedUser.status', 'status')
.where('lockedUser.id = :userId', { userId })
.setLock('pessimistic_write', undefined, ['lockedUser'])
.setLock('pessimistic_write')
.getOne();
if (!user) throw new NotFoundException('User not found');
return user;