fix: close admin user security gaps
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
|
||||
|
||||
describe('AuthController session enforcement', () => {
|
||||
it('protects GET auth/me with JWT validation', () => {
|
||||
@@ -15,4 +16,15 @@ describe('AuthController session enforcement', () => {
|
||||
it('does not expose self-deletion that can race with an admin promotion', () => {
|
||||
expect(AuthController.prototype).not.toHaveProperty('delete');
|
||||
});
|
||||
|
||||
it('uses the narrow validated registration DTO instead of an untyped body', () => {
|
||||
const parameterTypes = Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
AuthController.prototype,
|
||||
'register',
|
||||
);
|
||||
|
||||
expect(parameterTypes[0]).toBe(AuthRegisterLoginDto);
|
||||
expect(AuthRegisterLoginDto.prototype).not.toHaveProperty('linkPlayerId');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ApiOkResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CreateInviteDTO } from './dto/create-invite.dto';
|
||||
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller({
|
||||
@@ -50,7 +51,7 @@ export class AuthController {
|
||||
|
||||
@Post('email/register')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async register(@Body() createUserDto: any) {
|
||||
async register(@Body() createUserDto: AuthRegisterLoginDto) {
|
||||
return this.service.register(createUserDto);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
let lockedUserQuery: any;
|
||||
let userRepository: any;
|
||||
let service: AuthService;
|
||||
let mailService: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jwtService = {
|
||||
@@ -24,8 +25,10 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
linkPlayerToUserId: jest.fn(),
|
||||
};
|
||||
logger = { info: jest.fn(), debug: jest.fn() };
|
||||
mailService = { userSignUp: jest.fn() };
|
||||
confirmationUser = user(StatusEnum.inactive);
|
||||
confirmationUser.hash = 'confirmation-hash';
|
||||
lockedUserQuery = {
|
||||
@@ -45,7 +48,7 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
jwtService,
|
||||
usersService,
|
||||
{} as any,
|
||||
{} as any,
|
||||
mailService,
|
||||
logger,
|
||||
dataSource,
|
||||
);
|
||||
@@ -70,10 +73,12 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
});
|
||||
|
||||
it('rejects social login when the existing account is inactive', async () => {
|
||||
const inactive = user(StatusEnum.inactive);
|
||||
usersService.findOne
|
||||
.mockResolvedValueOnce(inactive)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const inactive = socialUser(
|
||||
AuthProvidersEnum.google,
|
||||
RoleEnum.user,
|
||||
StatusEnum.inactive,
|
||||
);
|
||||
configureSocialQueries([inactive], inactive);
|
||||
|
||||
await expect(
|
||||
service.validateSocialLogin(AuthProvidersEnum.google, {
|
||||
@@ -88,6 +93,61 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
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);
|
||||
|
||||
@@ -154,6 +214,26 @@ describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
expect(userRepository.save).toHaveBeenCalledWith(confirmationUser);
|
||||
});
|
||||
|
||||
it('ignores a public registration player id and never mutates player ownership', async () => {
|
||||
usersService.create.mockResolvedValue({
|
||||
id: 8,
|
||||
email: 'new@example.com',
|
||||
});
|
||||
|
||||
await service.register({
|
||||
email: 'new@example.com',
|
||||
password: 'password',
|
||||
firstName: 'New',
|
||||
lastName: 'User',
|
||||
linkPlayerId: 101,
|
||||
} as any);
|
||||
|
||||
expect(usersService.create.mock.calls[0][0]).not.toHaveProperty(
|
||||
'linkPlayerId',
|
||||
);
|
||||
expect(usersService.linkPlayerToUserId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function user(statusId: StatusEnum) {
|
||||
return {
|
||||
id: 2,
|
||||
@@ -167,4 +247,45 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,59 +117,68 @@ export class AuthService {
|
||||
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) {
|
||||
await this.assertActiveUser(user);
|
||||
if (socialEmail && !userByEmail) {
|
||||
user.email = socialEmail;
|
||||
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,
|
||||
});
|
||||
}
|
||||
await this.usersService.update(user.id, user);
|
||||
} else if (userByEmail) {
|
||||
user = userByEmail;
|
||||
await this.assertActiveUser(user);
|
||||
} else {
|
||||
const role = plainToClass(Role, {
|
||||
id: RoleEnum.user,
|
||||
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,
|
||||
});
|
||||
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, user: currentUser };
|
||||
});
|
||||
|
||||
return {
|
||||
token: jwtToken,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
async register(dto: AuthRegisterLoginDto): Promise<void> {
|
||||
@@ -179,8 +188,10 @@ export class AuthService {
|
||||
.digest('hex');
|
||||
|
||||
const user = await this.usersService.create({
|
||||
...dto,
|
||||
email: dto.email,
|
||||
password: dto.password,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
role: {
|
||||
id: RoleEnum.user,
|
||||
} as Role,
|
||||
@@ -190,10 +201,6 @@ export class AuthService {
|
||||
hash,
|
||||
});
|
||||
|
||||
if (user && dto.linkPlayerId != null) {
|
||||
await this.usersService.linkPlayerToUserId(user, dto.linkPlayerId);
|
||||
}
|
||||
|
||||
await this.logger.info({
|
||||
event: 'user_create',
|
||||
details: `userId=${user.id}`,
|
||||
|
||||
@@ -23,7 +23,4 @@ export class AuthRegisterLoginDto {
|
||||
@ApiProperty({ example: 'Doe' })
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: 27 })
|
||||
linkPlayerId: number | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user