feat: add local auth service
This commit is contained in:
parent
c8b7a172a4
commit
0ec0ae442a
5 changed files with 559 additions and 1 deletions
|
|
@ -16,6 +16,7 @@ import {
|
|||
isInfraConfigTablePopulated,
|
||||
} from 'src/infra-config/helper';
|
||||
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
|
||||
import { LocalAuthService } from './local-auth.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -29,7 +30,7 @@ import { InfraConfigModule } from 'src/infra-config/infra-config.module';
|
|||
}),
|
||||
InfraConfigModule,
|
||||
],
|
||||
providers: [AuthService],
|
||||
providers: [AuthService, LocalAuthService],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {
|
||||
|
|
|
|||
29
packages/hoppscotch-backend/src/auth/dto/local-auth.dto.ts
Normal file
29
packages/hoppscotch-backend/src/auth/dto/local-auth.dto.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class LocalSignInDto {
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(40)
|
||||
@Matches(/^[a-zA-Z0-9_.-]+$/)
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(12)
|
||||
@MaxLength(256)
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class LocalSetupAdminDto extends LocalSignInDto {}
|
||||
|
||||
export class CreateLocalUserDto extends LocalSignInDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
displayName?: string;
|
||||
}
|
||||
284
packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts
Normal file
284
packages/hoppscotch-backend/src/auth/local-auth.service.spec.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { HttpStatus } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import {
|
||||
AUTH_ADMIN_REQUIRED,
|
||||
AUTH_INVALID_LOCAL_CREDENTIALS,
|
||||
AUTH_LOCAL_SETUP_NOT_ALLOWED,
|
||||
AUTH_USERNAME_ALREADY_EXISTS,
|
||||
} from 'src/errors';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { AuthTokens } from 'src/types/AuthTokens';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LocalAuthService } from './local-auth.service';
|
||||
|
||||
jest.mock('argon2', () => ({
|
||||
hash: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockAuthService = mockDeep<AuthService>();
|
||||
const mockConfigService = mockDeep<ConfigService>();
|
||||
|
||||
const localAuthService = new LocalAuthService(
|
||||
mockPrisma,
|
||||
mockAuthService,
|
||||
mockConfigService,
|
||||
);
|
||||
|
||||
const currentTime = new Date();
|
||||
|
||||
const user: AuthUser = {
|
||||
uid: 'user-1',
|
||||
username: 'dwight',
|
||||
email: null,
|
||||
displayName: 'Dwight Schrute',
|
||||
photoURL: null,
|
||||
isAdmin: false,
|
||||
refreshToken: null,
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: null,
|
||||
currentRESTSession: null,
|
||||
};
|
||||
|
||||
const adminUser: AuthUser = {
|
||||
...user,
|
||||
uid: 'admin-1',
|
||||
username: 'admin',
|
||||
isAdmin: true,
|
||||
};
|
||||
|
||||
const tokens: AuthTokens = {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
};
|
||||
|
||||
describe('LocalAuthService', () => {
|
||||
beforeEach(() => {
|
||||
mockReset(mockPrisma);
|
||||
mockReset(mockAuthService);
|
||||
mockReset(mockConfigService);
|
||||
jest.mocked(argon2.hash).mockReset();
|
||||
jest.mocked(argon2.verify).mockReset();
|
||||
|
||||
mockConfigService.get.mockReturnValue('LOCAL');
|
||||
mockAuthService.generateAuthTokens.mockResolvedValue(E.right(tokens));
|
||||
jest.mocked(argon2.hash).mockResolvedValue('hashed-password');
|
||||
});
|
||||
|
||||
it('creates the first local admin before onboarding is completed', async () => {
|
||||
mockPrisma.infraConfig.findUnique.mockResolvedValue({
|
||||
id: 'onboarding',
|
||||
name: InfraConfigEnum.ONBOARDING_COMPLETED,
|
||||
value: 'false',
|
||||
lastSyncedEnvFileValue: null,
|
||||
isEncrypted: false,
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
});
|
||||
mockPrisma.user.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.user.create.mockResolvedValue(adminUser);
|
||||
|
||||
const result = await localAuthService.setupFirstAdmin({
|
||||
username: ' Dwight ',
|
||||
password: 'strong-password',
|
||||
});
|
||||
|
||||
expect(mockPrisma.user.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
username: 'dwight',
|
||||
displayName: 'dwight',
|
||||
isAdmin: true,
|
||||
localCredential: {
|
||||
create: {
|
||||
passwordHash: 'hashed-password',
|
||||
},
|
||||
},
|
||||
providerAccounts: {
|
||||
create: {
|
||||
provider: 'LOCAL',
|
||||
providerAccountId: 'dwight',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqualRight(tokens);
|
||||
});
|
||||
|
||||
it('refuses setup admin after onboarding is completed', async () => {
|
||||
mockPrisma.infraConfig.findUnique.mockResolvedValue({
|
||||
id: 'onboarding',
|
||||
name: InfraConfigEnum.ONBOARDING_COMPLETED,
|
||||
value: 'true',
|
||||
lastSyncedEnvFileValue: null,
|
||||
isEncrypted: false,
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
});
|
||||
|
||||
const result = await localAuthService.setupFirstAdmin({
|
||||
username: 'dwight',
|
||||
password: 'strong-password',
|
||||
});
|
||||
|
||||
expect(result).toEqualLeft({
|
||||
message: AUTH_LOCAL_SETUP_NOT_ALLOWED,
|
||||
statusCode: HttpStatus.FORBIDDEN,
|
||||
});
|
||||
expect(mockPrisma.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signs in a local user with valid credentials', async () => {
|
||||
mockPrisma.user.findFirst.mockResolvedValue({
|
||||
...user,
|
||||
localCredential: {
|
||||
id: 'credential-1',
|
||||
userUid: user.uid,
|
||||
passwordHash: 'hashed-password',
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(argon2.verify).mockResolvedValue(true);
|
||||
|
||||
const result = await localAuthService.signIn({
|
||||
username: 'Dwight',
|
||||
password: 'strong-password',
|
||||
});
|
||||
|
||||
expect(mockPrisma.user.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
username: {
|
||||
equals: 'dwight',
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
include: {
|
||||
localCredential: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqualRight(tokens);
|
||||
});
|
||||
|
||||
it('returns generic invalid credentials for an unknown username', async () => {
|
||||
mockPrisma.user.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await localAuthService.signIn({
|
||||
username: 'unknown',
|
||||
password: 'strong-password',
|
||||
});
|
||||
|
||||
expect(result).toEqualLeft({
|
||||
message: AUTH_INVALID_LOCAL_CREDENTIALS,
|
||||
statusCode: HttpStatus.UNAUTHORIZED,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns generic invalid credentials for a bad password', async () => {
|
||||
mockPrisma.user.findFirst.mockResolvedValue({
|
||||
...user,
|
||||
localCredential: {
|
||||
id: 'credential-1',
|
||||
userUid: user.uid,
|
||||
passwordHash: 'hashed-password',
|
||||
createdOn: currentTime,
|
||||
updatedOn: currentTime,
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(argon2.verify).mockResolvedValue(false);
|
||||
|
||||
const result = await localAuthService.signIn({
|
||||
username: 'dwight',
|
||||
password: 'wrong-password',
|
||||
});
|
||||
|
||||
expect(result).toEqualLeft({
|
||||
message: AUTH_INVALID_LOCAL_CREDENTIALS,
|
||||
statusCode: HttpStatus.UNAUTHORIZED,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses duplicate username case-insensitively', async () => {
|
||||
mockPrisma.user.findFirst.mockResolvedValue(user);
|
||||
|
||||
const result = await localAuthService.createLocalUser(
|
||||
{
|
||||
username: 'Dwight',
|
||||
password: 'strong-password',
|
||||
displayName: 'Dwight Schrute',
|
||||
},
|
||||
adminUser,
|
||||
);
|
||||
|
||||
expect(result).toEqualLeft({
|
||||
message: AUTH_USERNAME_ALREADY_EXISTS,
|
||||
statusCode: HttpStatus.CONFLICT,
|
||||
});
|
||||
expect(mockPrisma.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a non-admin local user when requested by an admin', async () => {
|
||||
mockPrisma.user.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.user.create.mockResolvedValue(user);
|
||||
|
||||
const result = await localAuthService.createLocalUser(
|
||||
{
|
||||
username: 'Dwight',
|
||||
password: 'strong-password',
|
||||
displayName: 'Dwight Schrute',
|
||||
},
|
||||
adminUser,
|
||||
);
|
||||
|
||||
expect(mockPrisma.user.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
username: 'dwight',
|
||||
displayName: 'Dwight Schrute',
|
||||
isAdmin: false,
|
||||
localCredential: {
|
||||
create: {
|
||||
passwordHash: 'hashed-password',
|
||||
},
|
||||
},
|
||||
providerAccounts: {
|
||||
create: {
|
||||
provider: 'LOCAL',
|
||||
providerAccountId: 'dwight',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqualRight({
|
||||
uid: user.uid,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
photoURL: user.photoURL,
|
||||
isAdmin: user.isAdmin,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses local user creation when requester is not admin', async () => {
|
||||
const result = await localAuthService.createLocalUser(
|
||||
{
|
||||
username: 'jim',
|
||||
password: 'strong-password',
|
||||
displayName: 'Jim Halpert',
|
||||
},
|
||||
user,
|
||||
);
|
||||
|
||||
expect(result).toEqualLeft({
|
||||
message: AUTH_ADMIN_REQUIRED,
|
||||
statusCode: HttpStatus.FORBIDDEN,
|
||||
});
|
||||
expect(mockPrisma.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
210
packages/hoppscotch-backend/src/auth/local-auth.service.ts
Normal file
210
packages/hoppscotch-backend/src/auth/local-auth.service.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import {
|
||||
AUTH_ADMIN_REQUIRED,
|
||||
AUTH_INVALID_LOCAL_CREDENTIALS,
|
||||
AUTH_LOCAL_PROVIDER_DISABLED,
|
||||
AUTH_LOCAL_SETUP_NOT_ALLOWED,
|
||||
AUTH_USERNAME_ALREADY_EXISTS,
|
||||
} from 'src/errors';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { RESTError } from 'src/types/RESTError';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { AuthProvider, authProviderCheck } from './helper';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
CreateLocalUserDto,
|
||||
LocalSetupAdminDto,
|
||||
LocalSignInDto,
|
||||
} from './dto/local-auth.dto';
|
||||
|
||||
type LocalUserWithCredential = Awaited<
|
||||
ReturnType<PrismaService['user']['findFirst']>
|
||||
> & {
|
||||
localCredential?: {
|
||||
passwordHash: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
normalizeUsername(username: string) {
|
||||
return username.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private invalidCredentials() {
|
||||
return E.left(<RESTError>{
|
||||
message: AUTH_INVALID_LOCAL_CREDENTIALS,
|
||||
statusCode: HttpStatus.UNAUTHORIZED,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureLocalProviderEnabled() {
|
||||
const allowedProviders = this.configService.get<string>(
|
||||
'INFRA.VITE_ALLOWED_AUTH_PROVIDERS',
|
||||
);
|
||||
|
||||
if (!authProviderCheck(AuthProvider.LOCAL, allowedProviders)) {
|
||||
return E.left(<RESTError>{
|
||||
message: AUTH_LOCAL_PROVIDER_DISABLED,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
private async findUserByUsername(username: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
username: {
|
||||
equals: username,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
include: {
|
||||
localCredential: true,
|
||||
},
|
||||
}) as Promise<LocalUserWithCredential | null>;
|
||||
}
|
||||
|
||||
private async ensureUsernameAvailable(username: string) {
|
||||
const existingUser = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
username: {
|
||||
equals: username,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return E.left(<RESTError>{
|
||||
message: AUTH_USERNAME_ALREADY_EXISTS,
|
||||
statusCode: HttpStatus.CONFLICT,
|
||||
});
|
||||
}
|
||||
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
private async createLocalUserRecord(
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
isAdmin: boolean,
|
||||
) {
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
displayName,
|
||||
isAdmin,
|
||||
localCredential: {
|
||||
create: {
|
||||
passwordHash,
|
||||
},
|
||||
},
|
||||
providerAccounts: {
|
||||
create: {
|
||||
provider: AuthProvider.LOCAL,
|
||||
providerAccountId: username,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async setupFirstAdmin(dto: LocalSetupAdminDto) {
|
||||
const onboardingCompleted = await this.prisma.infraConfig.findUnique({
|
||||
where: {
|
||||
name: InfraConfigEnum.ONBOARDING_COMPLETED,
|
||||
},
|
||||
});
|
||||
|
||||
if (onboardingCompleted?.value === 'true') {
|
||||
return E.left(<RESTError>{
|
||||
message: AUTH_LOCAL_SETUP_NOT_ALLOWED,
|
||||
statusCode: HttpStatus.FORBIDDEN,
|
||||
});
|
||||
}
|
||||
|
||||
const username = this.normalizeUsername(dto.username);
|
||||
const usernameAvailable = await this.ensureUsernameAvailable(username);
|
||||
if (E.isLeft(usernameAvailable)) return usernameAvailable;
|
||||
|
||||
const user = await this.createLocalUserRecord(
|
||||
username,
|
||||
dto.password,
|
||||
username,
|
||||
true,
|
||||
);
|
||||
|
||||
return this.authService.generateAuthTokens(user.uid);
|
||||
}
|
||||
|
||||
async createLocalUser(dto: CreateLocalUserDto, requester: AuthUser) {
|
||||
if (!requester?.isAdmin) {
|
||||
return E.left(<RESTError>{
|
||||
message: AUTH_ADMIN_REQUIRED,
|
||||
statusCode: HttpStatus.FORBIDDEN,
|
||||
});
|
||||
}
|
||||
|
||||
const providerEnabled = await this.ensureLocalProviderEnabled();
|
||||
if (E.isLeft(providerEnabled)) return providerEnabled;
|
||||
|
||||
const username = this.normalizeUsername(dto.username);
|
||||
const usernameAvailable = await this.ensureUsernameAvailable(username);
|
||||
if (E.isLeft(usernameAvailable)) return usernameAvailable;
|
||||
|
||||
const user = await this.createLocalUserRecord(
|
||||
username,
|
||||
dto.password,
|
||||
dto.displayName || username,
|
||||
false,
|
||||
);
|
||||
|
||||
return E.right({
|
||||
uid: user.uid,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
photoURL: user.photoURL,
|
||||
isAdmin: user.isAdmin,
|
||||
});
|
||||
}
|
||||
|
||||
async signIn(dto: LocalSignInDto) {
|
||||
const providerEnabled = await this.ensureLocalProviderEnabled();
|
||||
if (E.isLeft(providerEnabled)) return providerEnabled;
|
||||
|
||||
const username = this.normalizeUsername(dto.username);
|
||||
const user = await this.findUserByUsername(username);
|
||||
|
||||
if (!user?.localCredential?.passwordHash) {
|
||||
return this.invalidCredentials();
|
||||
}
|
||||
|
||||
const passwordMatches = await argon2.verify(
|
||||
user.localCredential.passwordHash,
|
||||
dto.password,
|
||||
);
|
||||
|
||||
if (!passwordMatches) {
|
||||
return this.invalidCredentials();
|
||||
}
|
||||
|
||||
return this.authService.generateAuthTokens(user.uid);
|
||||
}
|
||||
}
|
||||
|
|
@ -612,6 +612,40 @@ export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const;
|
|||
*/
|
||||
export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const;
|
||||
|
||||
/**
|
||||
* Local username/password authentication failed
|
||||
* (LocalAuthService)
|
||||
*/
|
||||
export const AUTH_INVALID_LOCAL_CREDENTIALS =
|
||||
'auth/invalid_local_credentials' as const;
|
||||
|
||||
/**
|
||||
* Local auth provider is not enabled
|
||||
* (LocalAuthService)
|
||||
*/
|
||||
export const AUTH_LOCAL_PROVIDER_DISABLED =
|
||||
'auth/local_provider_disabled' as const;
|
||||
|
||||
/**
|
||||
* Initial local admin setup is no longer allowed
|
||||
* (LocalAuthService)
|
||||
*/
|
||||
export const AUTH_LOCAL_SETUP_NOT_ALLOWED =
|
||||
'auth/local_setup_not_allowed' as const;
|
||||
|
||||
/**
|
||||
* Username is already used by another account
|
||||
* (LocalAuthService)
|
||||
*/
|
||||
export const AUTH_USERNAME_ALREADY_EXISTS =
|
||||
'auth/username_already_exists' as const;
|
||||
|
||||
/**
|
||||
* Admin privileges are required for this auth action
|
||||
* (LocalAuthService)
|
||||
*/
|
||||
export const AUTH_ADMIN_REQUIRED = 'auth/admin_required' as const;
|
||||
|
||||
/**
|
||||
* The provided title for the user collection is short (less than 3 characters)
|
||||
* (UserCollectionService)
|
||||
|
|
|
|||
Loading…
Reference in a new issue