first commit

This commit is contained in:
Bastian Wagner
2026-07-31 21:02:47 +02:00
commit 6bea4f766a
512 changed files with 64459 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,139 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Request,
Post,
UseGuards,
Patch,
Delete,
UseInterceptors,
ClassSerializerInterceptor,
SerializeOptions,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthEmailLoginDto } from './dto/auth-email-login.dto';
import { AuthForgotPasswordDto } from './dto/auth-forgot-password.dto';
import { AuthConfirmEmailDto } from './dto/auth-confirm-email.dto';
import { AuthResetPasswordDto } from './dto/auth-reset-password.dto';
import { AuthUpdateDto } from './dto/auth-update.dto';
import { AuthGuard } from '@nestjs/passport';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiOkResponse,
} from '@nestjs/swagger';
import { CreateInviteDTO } from './dto/create-invite.dto';
@ApiTags('Auth')
@Controller({
path: 'auth',
version: '1',
})
@UseInterceptors(ClassSerializerInterceptor)
export class AuthController {
constructor(public service: AuthService) {}
@Post('email/login')
@HttpCode(HttpStatus.OK)
public async login(@Body() loginDto: AuthEmailLoginDto) {
return this.service.validateLogin(loginDto);
}
@Post('admin/email/login')
@HttpCode(HttpStatus.OK)
public async adminLogin(@Body() loginDTO: AuthEmailLoginDto) {
return this.service.validateLogin(loginDTO);
}
@Post('email/register')
@HttpCode(HttpStatus.CREATED)
async register(@Body() createUserDto: any) {
return this.service.register(createUserDto);
}
@Post('email/confirm')
@HttpCode(HttpStatus.OK)
async confirmEmail(@Body() confirmEmailDto: AuthConfirmEmailDto) {
return this.service.confirmEmail(confirmEmailDto.hash);
}
@Post('forgot/password')
@HttpCode(HttpStatus.OK)
async forgotPassword(@Body() forgotPasswordDto: AuthForgotPasswordDto) {
return this.service.forgotPassword(forgotPasswordDto.email);
}
@Post('reset/password')
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() resetPasswordDto: AuthResetPasswordDto) {
return this.service.resetPassword(
resetPasswordDto.hash,
resetPasswordDto.password,
);
}
@ApiBearerAuth()
@SerializeOptions({
groups: ['exposeProvider'],
})
@Get('me')
// @UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
public me(@Request() request: Request) {
return this.service.me(request.headers['authorization']);
}
@ApiBearerAuth()
@Patch('me')
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
public async update(@Request() request, @Body() userDto: AuthUpdateDto) {
return this.service.update(request.user, userDto);
}
@ApiBearerAuth()
@Delete('me')
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
public async delete(@Request() request) {
return this.service.softDelete(request.user);
}
@ApiOperation({
summary: 'Erstellt Registrierungstoken',
description:
'Encoded den JWT Token für eine Einladung für eine neue Registrierung. Team- und Rollen-Infos müssen da sein. Der Token ist dann 30 Tage gültig.',
})
@ApiOkResponse({
description: 'JWT Token',
isArray: false,
type: 'string',
})
@Post('invite')
@UseGuards(AuthGuard('jwt'))
public getInvite(
@Body()
invite: any,
) {
return this.service.createTeamInvite(invite);
}
@ApiOperation({
summary: 'Verifiziert Registrierungstoken',
description:
'Decoded und prüft den JWT Token einer Einladung für die Registrierung',
})
@ApiOkResponse({
description: 'Object mit teamName, teamId, roleName, roleId',
isArray: false,
type: CreateInviteDTO,
})
@Post('verify-invite')
public verifyInvite(@Body() body: any) {
return this.service.getTeamFromInvite(body.token);
}
}

View File

@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './strategies/jwt.strategy';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AnonymousStrategy } from './strategies/anonymous.strategy';
import { UsersModule } from 'src/users/users.module';
import { ForgotModule } from 'src/forgot/forgot.module';
import { MailModule } from 'src/mail/mail.module';
import { IsExist } from 'src/utils/validators/is-exists.validator';
import { IsNotExist } from 'src/utils/validators/is-not-exists.validator';
import { LoggingModule } from 'src/database/logging/logging.module';
@Module({
imports: [
UsersModule,
ForgotModule,
PassportModule,
MailModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get('auth.secret'),
signOptions: {
expiresIn: configService.get('auth.expires'),
},
}),
}),
LoggingModule,
],
controllers: [AuthController],
providers: [IsExist, IsNotExist, AuthService, JwtStrategy, AnonymousStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,405 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { User } from '../users/entities/user.entity';
import * as bcrypt from 'bcryptjs';
import { AuthEmailLoginDto } from './dto/auth-email-login.dto';
import { AuthUpdateDto } from './dto/auth-update.dto';
import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util';
import { RoleEnum } from 'src/roles/roles.enum';
import { StatusEnum } from 'src/statuses/statuses.enum';
import * as crypto from 'crypto';
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';
import { MailService } from 'src/mail/mail.service';
import { CreateInviteDTO } from './dto/create-invite.dto';
import { LoggingService } from 'src/database/logging/logging.service';
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private usersService: UsersService,
private forgotService: ForgotService,
private mailService: MailService,
private logger: LoggingService,
) {}
async validateLogin(
loginDto: AuthEmailLoginDto,
): Promise<{ token: string; user: User }> {
const user = await this.usersService.findOne({
email: loginDto.email,
});
if (!user) {
await this.logger.info({
event: 'user_login_fail',
details: `mail not found: ${loginDto.email}`,
userId: -1,
});
throw new HttpException(
{
status: HttpStatus.UNPROCESSABLE_ENTITY,
errors: {
email: 'notFound',
},
},
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
if (user.provider !== AuthProvidersEnum.email) {
throw new HttpException(
{
status: HttpStatus.UNPROCESSABLE_ENTITY,
errors: {
email: `needLoginViaProvider:${user.provider}`,
},
},
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
const isValidPassword = await bcrypt.compare(
loginDto.password,
user.password,
);
if (isValidPassword) {
const token = await this.jwtService.sign({
id: user.id,
role: user.role,
});
await this.logger.info({
event: 'user_login_success',
details: `logged in: ${loginDto.email}`,
userId: user.id,
});
return { token, user: user };
} else {
await this.logger.info({
event: 'user_login_fail',
details: `incorrect password for user: ${loginDto.email}`,
userId: user.id,
});
throw new HttpException(
{
status: HttpStatus.UNPROCESSABLE_ENTITY,
errors: {
password: 'incorrectPassword',
},
},
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
}
async validateSocialLogin(
authProvider: string,
socialData: SocialInterface,
): Promise<{ token: string; user: User }> {
let user: User;
const socialEmail = socialData.email?.toLowerCase();
const userByEmail = await this.usersService.findOne({
email: socialEmail,
});
user = await this.usersService.findOne({
socialId: socialData.id,
provider: authProvider,
});
if (user) {
if (socialEmail && !userByEmail) {
user.email = socialEmail;
}
await this.usersService.update(user.id, user);
} else if (userByEmail) {
user = userByEmail;
} else {
const role = plainToClass(Role, {
id: RoleEnum.user,
});
const status = plainToClass(Status, {
id: StatusEnum.active,
});
user = await this.usersService.create({
email: socialEmail,
firstName: socialData.firstName,
lastName: socialData.lastName,
socialId: socialData.id,
provider: authProvider,
role,
status,
});
user = await this.usersService.findOne({
id: user.id,
});
}
const jwtToken = await this.jwtService.sign({
id: user.id,
role: user.role,
});
return {
token: jwtToken,
user,
};
}
async register(dto: AuthRegisterLoginDto): Promise<void> {
const hash = crypto
.createHash('sha256')
.update(randomStringGenerator())
.digest('hex');
const user = await this.usersService.create({
...dto,
email: dto.email,
role: {
id: RoleEnum.user,
} as Role,
status: {
id: StatusEnum.inactive,
} as Status,
hash,
});
if (user && dto.linkPlayerId != null) {
await this.usersService.linkPlayerToUserId(user, dto.linkPlayerId);
}
await this.logger.info({
event: 'user_create',
details: `user created with mail: ${dto.email}`,
userId: user.id,
});
await this.mailService.userSignUp({
to: user.email,
data: {
hash,
},
});
}
async confirmEmail(hash: string): Promise<void> {
const user = await this.usersService.findOne({
hash,
});
if (!user) {
throw new HttpException(
{
status: HttpStatus.NOT_FOUND,
error: `notFound`,
},
HttpStatus.NOT_FOUND,
);
}
user.hash = null;
user.status = plainToClass(Status, {
id: StatusEnum.active,
});
await user.save();
}
async forgotPassword(email: string): Promise<void> {
const user = await this.usersService.findOne({
email,
});
if (!user) {
throw new HttpException(
{
status: HttpStatus.UNPROCESSABLE_ENTITY,
errors: {
email: 'emailNotExists',
},
},
HttpStatus.UNPROCESSABLE_ENTITY,
);
} else {
const hash = crypto
.createHash('sha256')
.update(randomStringGenerator())
.digest('hex');
await this.forgotService.create({
hash,
user,
});
await this.mailService.forgotPassword({
to: email,
data: {
hash,
},
});
}
}
async resetPassword(hash: string, password: string): Promise<void> {
const forgot = await this.forgotService.findOne({
where: {
hash,
},
});
if (!forgot) {
throw new HttpException(
{
status: HttpStatus.UNPROCESSABLE_ENTITY,
errors: {
hash: `notFound`,
},
},
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
const user = forgot.user;
user.password = password;
await user.save();
await this.forgotService.softDelete(forgot.id);
}
async me(token: string): Promise<User> {
token = token.replace('Bearer ', '');
let role: any;
try {
role = this.jwtService.verify(token);
const u = await this.usersService.findOne({
id: role.id,
});
await this.logger.debug({
event: 'user_token_verification_success',
details: `Email: ${u.email}`,
userId: u.id,
});
return u;
} catch (error) {
const role = this.jwtService.decode(token);
const user = await this.usersService.findOne({
id: (role as any).id,
});
const t = await this.jwtService.sign({
id: user.id,
role: user.role,
});
user['token'] = t;
await this.logger.debug({
event: 'user_token_verification_success',
details: `Email: ${user.email}`,
userId: user.id,
});
return user;
}
}
async update(user: User, userDto: AuthUpdateDto): Promise<User> {
if (userDto.password) {
if (userDto.oldPassword) {
const currentUser = await this.usersService.findOne({
id: user.id,
});
const isValidOldPassword = await bcrypt.compare(
userDto.oldPassword,
currentUser.password,
);
if (!isValidOldPassword) {
throw new HttpException(
{
status: HttpStatus.UNPROCESSABLE_ENTITY,
errors: {
oldPassword: 'incorrectOldPassword',
},
},
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
} else {
throw new HttpException(
{
status: HttpStatus.UNPROCESSABLE_ENTITY,
errors: {
oldPassword: 'missingOldPassword',
},
},
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
}
await this.usersService.update(user.id, userDto);
return this.usersService.findOne({
id: user.id,
});
}
async softDelete(user: User): Promise<void> {
await this.usersService.softDelete(user.id);
}
async createTeamInvite(object: CreateInviteDTO) {
const token = await this.jwtService.sign(object, {
expiresIn: '30d',
});
await this.logger.info({
event: 'user_invite_link_create',
details: `invitation created for team: ${object.teamName} , ${object.teamId}`,
userId: 0,
});
return { token };
}
async getTeamFromInvite(token: string) {
try {
const teamInfo = this.jwtService.verify(token);
delete teamInfo.iat;
delete teamInfo.exp;
await this.logger.info({
event: 'user_invite_link_validate',
details: `invitation validated for team: ${teamInfo.teamName} , ${teamInfo.teamId}`,
userId: 0,
});
return teamInfo;
} catch {
await this.logger.info({
event: 'user_invite_link_validate_fail',
details: `validation failed for token ${token}`,
userId: 0,
});
throw new HttpException(
'Token not valid',
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
}
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
export class AuthConfirmEmailDto {
@ApiProperty()
@IsNotEmpty()
hash: string;
}

View File

@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, Validate } from 'class-validator';
import { IsExist } from 'src/utils/validators/is-exists.validator';
import { Transform } from 'class-transformer';
export class AuthEmailLoginDto {
@ApiProperty({ example: 'test1@example.com' })
@Transform(({ value }) => value.toLowerCase().trim())
@Validate(IsExist, ['User'], {
message: 'emailNotExists',
})
email: string;
@ApiProperty()
@IsNotEmpty()
password: string;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';
import { Transform } from 'class-transformer';
export class AuthForgotPasswordDto {
@ApiProperty()
@Transform(({ value }) => value.toLowerCase().trim())
@IsEmail()
email: string;
}

View File

@@ -0,0 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, MinLength, Validate } from 'class-validator';
import { IsNotExist } from 'src/utils/validators/is-not-exists.validator';
import { Transform } from 'class-transformer';
export class AuthRegisterLoginDto {
@ApiProperty({ example: 'test1@example.com' })
@Transform(({ value }) => value.toLowerCase().trim())
@Validate(IsNotExist, ['User'], {
message: 'emailAlreadyExists',
})
@IsEmail()
email: string;
@ApiProperty()
@MinLength(6)
password: string;
@ApiProperty({ example: 'John' })
@IsNotEmpty()
firstName: string;
@ApiProperty({ example: 'Doe' })
@IsNotEmpty()
lastName: string;
@ApiProperty({ example: 27 })
linkPlayerId: number | null;
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
export class AuthResetPasswordDto {
@ApiProperty()
@IsNotEmpty()
password: string;
@ApiProperty()
@IsNotEmpty()
hash: string;
}

View File

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, MinLength, Validate } from 'class-validator';
import { IsExist } from '../../utils/validators/is-exists.validator';
import { FileEntity } from '../../files/entities/file.entity';
export class AuthUpdateDto {
@ApiProperty({ type: () => FileEntity })
@IsOptional()
@Validate(IsExist, ['FileEntity', 'id'], {
message: 'imageNotExists',
})
photo?: FileEntity;
@ApiProperty({ example: 'John' })
@IsOptional()
@IsNotEmpty({ message: 'mustBeNotEmpty' })
firstName?: string;
@ApiProperty({ example: 'Doe' })
@IsOptional()
@IsNotEmpty({ message: 'mustBeNotEmpty' })
lastName?: string;
@ApiProperty()
@IsOptional()
@IsNotEmpty()
@MinLength(6)
password?: string;
@ApiProperty()
@IsOptional()
@IsNotEmpty({ message: 'mustBeNotEmpty' })
oldPassword: string;
}

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
export class CreateInviteDTO {
@ApiProperty()
teamId: number;
@ApiProperty({ example: 'Development Team' })
teamName: string;
@ApiProperty()
playerId: number;
@ApiProperty({ example: 'Max Mustermann' })
playerName: string;
}

View File

@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class VerifyTokenDTO {
@ApiProperty({ example: 'JWT' })
token: string;
}

View File

@@ -0,0 +1,14 @@
import { Strategy } from 'passport-anonymous';
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
@Injectable()
export class AnonymousStrategy extends PassportStrategy(Strategy) {
constructor() {
super();
}
public validate(payload: unknown, request: unknown): unknown {
return request;
}
}

View File

@@ -0,0 +1,28 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PassportStrategy } from '@nestjs/passport';
import { User } from '../../users/entities/user.entity';
import { ConfigService } from '@nestjs/config';
type JwtPayload = Pick<User, 'id' | 'role'> & { iat: number; exp: number };
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get('auth.secret'),
});
}
public validate(payload: JwtPayload) {
if (!payload.id) {
throw new UnauthorizedException();
}
return payload;
}
}