api-client/packages/hoppscotch-backend/src/auth/local-auth.service.ts
2026-05-06 08:27:26 +02:00

210 lines
5.3 KiB
TypeScript

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);
}
}