210 lines
5.3 KiB
TypeScript
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);
|
|
}
|
|
}
|