feat: secure admin user management
This commit is contained in:
18
myteamwallet_backend/src/auth/auth.controller.spec.ts
Normal file
18
myteamwallet_backend/src/auth/auth.controller.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
170
myteamwallet_backend/src/auth/auth.service.spec.ts
Normal file
170
myteamwallet_backend/src/auth/auth.service.spec.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user