feat: secure admin user management

This commit is contained in:
Bastian Wagner
2026-07-31 23:37:54 +02:00
parent 4105460400
commit e6acfdcac7
22 changed files with 1598 additions and 148 deletions

View File

@@ -0,0 +1,18 @@
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { AuthController } from './auth.controller';
describe('AuthController session enforcement', () => {
it('protects GET auth/me with JWT validation', () => {
const guards = Reflect.getMetadata(
GUARDS_METADATA,
AuthController.prototype.me,
);
expect(guards).toBeDefined();
expect(guards).toHaveLength(1);
});
it('does not expose self-deletion that can race with an admin promotion', () => {
expect(AuthController.prototype).not.toHaveProperty('delete');
});
});

View File

@@ -8,7 +8,6 @@ import {
Post,
UseGuards,
Patch,
Delete,
UseInterceptors,
ClassSerializerInterceptor,
SerializeOptions,
@@ -81,7 +80,7 @@ export class AuthController {
groups: ['exposeProvider'],
})
@Get('me')
// @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
public me(@Request() request: Request) {
return this.service.me(request.headers['authorization']);
@@ -95,14 +94,6 @@ export class AuthController {
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:

View File

@@ -0,0 +1,170 @@
import { ForbiddenException } from '@nestjs/common';
import { AuthProvidersEnum } from './auth-providers.enum';
import { AuthService } from './auth.service';
import { RoleEnum } from '../roles/roles.enum';
import { StatusEnum } from '../statuses/statuses.enum';
describe('AuthService inactive-user enforcement and safe logging', () => {
let jwtService: any;
let usersService: any;
let logger: any;
let dataSource: any;
let confirmationUser: any;
let lockedUserQuery: any;
let userRepository: any;
let service: AuthService;
beforeEach(() => {
jwtService = {
sign: jest.fn(() => 'signed-token'),
verify: jest.fn(),
decode: jest.fn(),
};
usersService = {
findOne: jest.fn(),
update: jest.fn(),
create: jest.fn(),
};
logger = { info: jest.fn(), debug: jest.fn() };
confirmationUser = user(StatusEnum.inactive);
confirmationUser.hash = 'confirmation-hash';
lockedUserQuery = {
where: jest.fn().mockReturnThis(),
setLock: jest.fn().mockReturnThis(),
getOne: jest.fn(() => confirmationUser),
};
userRepository = {
createQueryBuilder: jest.fn(() => lockedUserQuery),
save: jest.fn((value) => Promise.resolve(value)),
};
const manager = { getRepository: jest.fn(() => userRepository) };
dataSource = {
transaction: jest.fn((work) => work(manager)),
};
service = new AuthService(
jwtService,
usersService,
{} as any,
{} as any,
logger,
dataSource,
);
});
it('rejects password login for an inactive user before issuing a token', async () => {
usersService.findOne.mockResolvedValue(user(StatusEnum.inactive));
await expect(
service.validateLogin({
email: 'inactive@example.com',
password: 'password',
}),
).rejects.toBeInstanceOf(ForbiddenException);
expect(jwtService.sign).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith({
event: 'user_login_fail',
details: 'userId=2 reason=inactive',
userId: 2,
});
});
it('rejects social login when the existing account is inactive', async () => {
const inactive = user(StatusEnum.inactive);
usersService.findOne
.mockResolvedValueOnce(inactive)
.mockResolvedValueOnce(undefined);
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('never includes an email in an unknown-user login audit event', async () => {
usersService.findOne.mockResolvedValue(undefined);
await expect(
service.validateLogin({
email: 'secret@example.com',
password: 'secret-password',
}),
).rejects.toBeDefined();
expect(logger.info).toHaveBeenCalledWith({
event: 'user_login_fail',
details: 'reason=user_not_found',
userId: -1,
});
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
'secret@example.com',
);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
'secret-password',
);
});
it('never includes a rejected invite token in logging details', async () => {
jwtService.verify.mockImplementation(() => {
throw new Error('invalid');
});
await expect(
service.getTeamFromInvite('secret-token'),
).rejects.toBeDefined();
expect(logger.info).toHaveBeenCalledWith({
event: 'user_invite_link_validate_fail',
details: 'invitation validation failed',
userId: 0,
});
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
'secret-token',
);
});
it('does not refresh an inactive user through the me endpoint flow', async () => {
jwtService.verify.mockReturnValue({ id: 2 });
usersService.findOne.mockResolvedValue(user(StatusEnum.inactive));
await expect(service.me('Bearer existing-token')).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(jwtService.sign).not.toHaveBeenCalled();
});
it('serializes email confirmation on the user row and consumes the hash', async () => {
await service.confirmEmail('confirmation-hash');
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(lockedUserQuery.setLock).toHaveBeenCalledWith(
'pessimistic_write',
undefined,
['user'],
);
expect(confirmationUser.status).toEqual({ id: StatusEnum.active });
expect(confirmationUser.hash).toBeNull();
expect(userRepository.save).toHaveBeenCalledWith(confirmationUser);
});
function user(statusId: StatusEnum) {
return {
id: 2,
email: 'inactive@example.com',
password: 'password-hash',
provider: AuthProvidersEnum.email,
role: { id: RoleEnum.user, name: 'User' },
status: {
id: statusId,
name: statusId === StatusEnum.active ? 'Active' : 'Inactive',
},
};
}
});

View File

@@ -1,4 +1,10 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { User } from '../users/entities/user.entity';
import * as bcrypt from 'bcryptjs';
@@ -19,6 +25,7 @@ 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';
import { DataSource } from 'typeorm';
@Injectable()
export class AuthService {
@@ -28,6 +35,7 @@ export class AuthService {
private forgotService: ForgotService,
private mailService: MailService,
private logger: LoggingService,
private dataSource: DataSource,
) {}
async validateLogin(
@@ -40,7 +48,7 @@ export class AuthService {
if (!user) {
await this.logger.info({
event: 'user_login_fail',
details: `mail not found: ${loginDto.email}`,
details: 'reason=user_not_found',
userId: -1,
});
throw new HttpException(
@@ -54,6 +62,8 @@ export class AuthService {
);
}
await this.assertActiveUser(user);
if (user.provider !== AuthProvidersEnum.email) {
throw new HttpException(
{
@@ -79,7 +89,7 @@ export class AuthService {
await this.logger.info({
event: 'user_login_success',
details: `logged in: ${loginDto.email}`,
details: `userId=${user.id}`,
userId: user.id,
});
@@ -87,7 +97,7 @@ export class AuthService {
} else {
await this.logger.info({
event: 'user_login_fail',
details: `incorrect password for user: ${loginDto.email}`,
details: `userId=${user.id} reason=incorrect_password`,
userId: user.id,
});
@@ -120,12 +130,14 @@ export class AuthService {
});
if (user) {
await this.assertActiveUser(user);
if (socialEmail && !userByEmail) {
user.email = socialEmail;
}
await this.usersService.update(user.id, user);
} else if (userByEmail) {
user = userByEmail;
await this.assertActiveUser(user);
} else {
const role = plainToClass(Role, {
id: RoleEnum.user,
@@ -184,7 +196,7 @@ export class AuthService {
await this.logger.info({
event: 'user_create',
details: `user created with mail: ${dto.email}`,
details: `userId=${user.id}`,
userId: user.id,
});
@@ -197,25 +209,26 @@ export class AuthService {
}
async confirmEmail(hash: string): Promise<void> {
const user = await this.usersService.findOne({
hash,
await this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(User);
const user = await repository
.createQueryBuilder('user')
.where('user.hash = :hash', { hash })
.setLock('pessimistic_write', undefined, ['user'])
.getOne();
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 repository.save(user);
});
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> {
@@ -279,40 +292,31 @@ export class AuthService {
async me(token: string): Promise<User> {
token = token.replace('Bearer ', '');
let role: any;
let payload: any;
let refreshToken = false;
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,
});
payload = this.jwtService.verify(token);
} catch {
payload = this.jwtService.decode(token);
refreshToken = true;
}
if (!payload?.id) throw new UnauthorizedException();
const user = await this.usersService.findOne({ id: payload.id });
if (!user) throw new UnauthorizedException();
await this.assertActiveUser(user);
if (refreshToken) {
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;
}
await this.logger.debug({
event: 'user_token_verification_success',
details: `userId=${user.id}`,
userId: user.id,
});
return user;
}
async update(user: User, userDto: AuthUpdateDto): Promise<User> {
@@ -358,10 +362,6 @@ export class AuthService {
});
}
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',
@@ -392,7 +392,7 @@ export class AuthService {
} catch {
await this.logger.info({
event: 'user_invite_link_validate_fail',
details: `validation failed for token ${token}`,
details: 'invitation validation failed',
userId: 0,
});
@@ -402,4 +402,14 @@ export class AuthService {
);
}
}
private async assertActiveUser(user: User): Promise<void> {
if (user.status?.id === StatusEnum.active) return;
await this.logger.info({
event: 'user_login_fail',
details: `userId=${user.id} reason=inactive`,
userId: user.id,
});
throw new ForbiddenException('User account is inactive');
}
}

View File

@@ -0,0 +1,67 @@
import { UnauthorizedException } from '@nestjs/common';
import { RoleEnum } from '../../roles/roles.enum';
import { StatusEnum } from '../../statuses/statuses.enum';
import { JwtStrategy } from './jwt.strategy';
describe('JwtStrategy', () => {
const jwtService = {} as any;
const configService = { get: jest.fn(() => 'secret') } as any;
let usersService: any;
let strategy: JwtStrategy;
beforeEach(() => {
usersService = { findOne: jest.fn() };
strategy = new JwtStrategy(jwtService, configService, usersService);
});
it('reloads the current database user and replaces a stale token role', async () => {
usersService.findOne.mockResolvedValue({
id: 2,
role: { id: RoleEnum.user, name: 'User' },
status: { id: StatusEnum.active, name: 'Active' },
password: 'must-not-be-exposed',
});
const result = await strategy.validate({
id: 2,
role: { id: RoleEnum.admin },
iat: 1,
exp: 2,
} as any);
expect(usersService.findOne).toHaveBeenCalledWith({ id: 2 });
expect(result).toEqual({
id: 2,
role: { id: RoleEnum.user, name: 'User' },
status: { id: StatusEnum.active, name: 'Active' },
});
expect(result).not.toHaveProperty('password');
});
it('rejects a missing or soft-deleted database user', async () => {
usersService.findOne.mockResolvedValue(undefined);
await expect(
strategy.validate({ id: 2, iat: 1, exp: 2 } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects an inactive current database user', async () => {
usersService.findOne.mockResolvedValue({
id: 2,
role: { id: RoleEnum.user },
status: { id: StatusEnum.inactive },
});
await expect(
strategy.validate({ id: 2, iat: 1, exp: 2 } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects a payload without an id without querying the database', async () => {
await expect(
strategy.validate({ iat: 1, exp: 2 } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(usersService.findOne).not.toHaveBeenCalled();
});
});

View File

@@ -4,6 +4,8 @@ import { JwtService } from '@nestjs/jwt';
import { PassportStrategy } from '@nestjs/passport';
import { User } from '../../users/entities/user.entity';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../../users/users.service';
import { StatusEnum } from '../../statuses/statuses.enum';
type JwtPayload = Pick<User, 'id' | 'role'> & { iat: number; exp: number };
@@ -12,6 +14,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private usersService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -19,10 +22,18 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}
public validate(payload: JwtPayload) {
public async validate(payload: JwtPayload) {
if (!payload.id) {
throw new UnauthorizedException();
}
return payload;
const user = await this.usersService.findOne({ id: payload.id });
if (!user || user.status?.id !== StatusEnum.active) {
throw new UnauthorizedException();
}
return {
id: user.id,
role: user.role ?? null,
status: user.status ?? null,
};
}
}