feat: magic-link auth complete
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpStatus, Post, Res } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { signInMagicDto } from './dto/signin-magic.dto';
|
||||
import { verifyMagicDto } from './dto/verify-magic.dto';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -11,4 +12,23 @@ export class AuthController {
|
||||
async signIn(@Body() authData: signInMagicDto) {
|
||||
return this.authService.signIn(authData.email);
|
||||
}
|
||||
|
||||
//TODO: set expiresOn to cookies
|
||||
@Post('verify')
|
||||
async verify(@Body() data: verifyMagicDto, @Res() res: Response) {
|
||||
const authTokens = await this.authService.verify(data);
|
||||
res.cookie('access_token', authTokens.access_token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
res.cookie('refresh_token', authTokens.refresh_token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
res.status(HttpStatus.OK).json({
|
||||
message: 'Successfully logged in',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,21 @@ import { AuthController } from './auth.controller';
|
||||
import { UserModule } from 'src/user/user.module';
|
||||
import { MailerModule } from 'src/mailer/mailer.module';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtModule } from '@nestjs/jwt/dist';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UserModule, MailerModule],
|
||||
providers: [AuthService],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
UserModule,
|
||||
MailerModule,
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET,
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -6,21 +6,37 @@ import { UserService } from 'src/user/user.service';
|
||||
import { verifyMagicDto } from './dto/verify-magic.dto';
|
||||
import { DateTime } from 'luxon';
|
||||
import * as argon2 from 'argon2';
|
||||
import bcrypt from 'bcrypt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import * as TE from 'fp-ts/TaskEither';
|
||||
import { PasswordlessToken } from 'src/types/Passwordless';
|
||||
import { EmailCodec } from 'src/types/Email';
|
||||
import { INVALID_EMAIL } from 'src/errors';
|
||||
import {
|
||||
INVALID_EMAIL,
|
||||
INVALID_MAGIC_LINK_DATA,
|
||||
PASSWORDLESS_DATA_NOT_FOUND,
|
||||
MAGIC_LINK_EXPIRED,
|
||||
TOKEN_EXPIRED,
|
||||
USER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { pipe } from 'fp-ts/lib/function';
|
||||
import { validateEmail } from 'src/utils';
|
||||
import { throwErr, validateEmail } from 'src/utils';
|
||||
import {
|
||||
AccessTokenPayload,
|
||||
AuthTokens,
|
||||
RefreshTokenPayload,
|
||||
} from 'src/types/AuthTokens';
|
||||
import { ProviderAccount } from 'src/types/ProviderAccount';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { pass } from 'fp-ts/lib/Writer';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private usersService: UserService,
|
||||
private prismaService: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private readonly mailerService: MailerService,
|
||||
) {}
|
||||
|
||||
@@ -32,30 +48,140 @@ export class AuthService {
|
||||
const idToken: PasswordlessToken =
|
||||
await this.prismaService.passwordlessVerification.create({
|
||||
data: {
|
||||
expiresOn: expiresOn,
|
||||
deviceIdentifier: salt,
|
||||
userUid: user.id,
|
||||
expiresOn: expiresOn,
|
||||
},
|
||||
});
|
||||
|
||||
return idToken;
|
||||
}
|
||||
|
||||
private async validatePasswordlessTokens(data: verifyMagicDto) {
|
||||
try {
|
||||
const tokens: PasswordlessToken =
|
||||
await this.prismaService.passwordlessVerification.findUniqueOrThrow({
|
||||
where: {
|
||||
passwordless_deviceIdentifier_tokens: {
|
||||
deviceIdentifier: data.deviceIdentifier,
|
||||
token: data.token,
|
||||
},
|
||||
},
|
||||
});
|
||||
return O.some(tokens);
|
||||
} catch (error) {
|
||||
return O.none;
|
||||
}
|
||||
}
|
||||
|
||||
private async UpdateUserRefreshToken(tokenHash: string, userUid: string) {
|
||||
try {
|
||||
const user: User = await this.prismaService.user.update({
|
||||
where: {
|
||||
id: userUid,
|
||||
},
|
||||
data: {
|
||||
refreshToken: tokenHash,
|
||||
},
|
||||
});
|
||||
|
||||
return E.right(user);
|
||||
} catch (error) {
|
||||
return E.left(USER_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
private async generateRefreshToken(userUid: string) {
|
||||
const refreshTokenPayload: RefreshTokenPayload = {
|
||||
iss: process.env.APP_DOMAIN,
|
||||
sub: userUid,
|
||||
aud: [process.env.APP_DOMAIN],
|
||||
};
|
||||
|
||||
const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
|
||||
expiresIn: process.env.REFRESH_TOKEN_VALIDITY, //7 Days
|
||||
});
|
||||
|
||||
const refreshTokenHash = await argon2.hash(refreshToken);
|
||||
|
||||
const updatedUser = await this.UpdateUserRefreshToken(
|
||||
refreshTokenHash,
|
||||
userUid,
|
||||
);
|
||||
if (E.isLeft(updatedUser))
|
||||
throw new HttpException(updatedUser.left, HttpStatus.NOT_FOUND);
|
||||
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
async generateAuthTokens(userUid: string) {
|
||||
const accessTokenPayload: AccessTokenPayload = {
|
||||
iss: process.env.APP_DOMAIN,
|
||||
sub: userUid,
|
||||
aud: [process.env.APP_DOMAIN],
|
||||
};
|
||||
|
||||
const refreshToken = await this.generateRefreshToken(userUid);
|
||||
|
||||
return <AuthTokens>{
|
||||
access_token: await this.jwtService.sign(accessTokenPayload, {
|
||||
expiresIn: process.env.ACCESS_TOKEN_VALIDITY, //1 Day
|
||||
}),
|
||||
refresh_token: refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async deletePasswordlessVerificationToken(
|
||||
passwordlessTokens: PasswordlessToken,
|
||||
) {
|
||||
try {
|
||||
const deletedPasswordlessToken =
|
||||
await this.prismaService.passwordlessVerification.delete({
|
||||
where: {
|
||||
passwordless_deviceIdentifier_tokens: {
|
||||
deviceIdentifier: passwordlessTokens.deviceIdentifier,
|
||||
token: passwordlessTokens.token,
|
||||
},
|
||||
},
|
||||
});
|
||||
return E.right(deletedPasswordlessToken);
|
||||
} catch (error) {
|
||||
return E.left(PASSWORDLESS_DATA_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
async checkIfProviderAccountExists(user: User, profile) {
|
||||
const provider: ProviderAccount =
|
||||
await this.prismaService.account.findUnique({
|
||||
where: {
|
||||
verifyProviderAccount: {
|
||||
provider: profile.provider,
|
||||
providerAccountId: profile.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!provider) return O.none;
|
||||
|
||||
return O.some(provider);
|
||||
}
|
||||
|
||||
async signIn(email: string) {
|
||||
if (!validateEmail(email)) return E.left(INVALID_EMAIL);
|
||||
if (!validateEmail(email))
|
||||
throw new HttpException(INVALID_EMAIL, HttpStatus.BAD_REQUEST);
|
||||
|
||||
let user: User;
|
||||
const queriedUser = await this.usersService.findUserByEmail(email);
|
||||
|
||||
if (O.isNone(queriedUser)) {
|
||||
user = await this.usersService.createUser(email);
|
||||
user = await this.usersService.createUserMagic(email);
|
||||
} else {
|
||||
user = queriedUser.value;
|
||||
}
|
||||
|
||||
const generatedTokens = await this.generatePasswordlessTokens(user);
|
||||
|
||||
this.mailerService.sendMail(email, {
|
||||
await this.mailerService.sendAuthEmail(email, {
|
||||
template: 'code-your-own',
|
||||
variables: {
|
||||
inviteeEmail: email,
|
||||
@@ -65,4 +191,29 @@ export class AuthService {
|
||||
|
||||
return { deviceIdentifier: generatedTokens.deviceIdentifier };
|
||||
}
|
||||
|
||||
async verify(data: verifyMagicDto) {
|
||||
const passwordlessTokens = await this.validatePasswordlessTokens(data);
|
||||
if (O.isNone(passwordlessTokens))
|
||||
throw new HttpException(INVALID_MAGIC_LINK_DATA, HttpStatus.NOT_FOUND);
|
||||
|
||||
const currentTime = DateTime.now().toISOTime();
|
||||
|
||||
if (currentTime > passwordlessTokens.value.expiresOn.toISOString()) {
|
||||
throw new HttpException(MAGIC_LINK_EXPIRED, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
const tokens = await this.generateAuthTokens(
|
||||
passwordlessTokens.value.userUid,
|
||||
);
|
||||
|
||||
const deletedPasswordlessToken =
|
||||
await this.deletePasswordlessVerificationToken(passwordlessTokens.value);
|
||||
if (E.isLeft(deletedPasswordlessToken))
|
||||
throw new HttpException(
|
||||
deletedPasswordlessToken.left,
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export class verifyMagicDto {
|
||||
identifier: string;
|
||||
deviceIdentifier: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import {
|
||||
Injectable,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AccessTokenPayload } from 'src/types/AuthTokens';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { Request } from 'express';
|
||||
import * as O from 'fp-ts/Option';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
private usersService: UserService,
|
||||
private authService: AuthService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(request: Request) => {
|
||||
const ATCookie = request.cookies['access_token'];
|
||||
if (!ATCookie) {
|
||||
throw new ForbiddenException('No cookies present');
|
||||
}
|
||||
return ATCookie;
|
||||
},
|
||||
]),
|
||||
secretOrKey: process.env.JWT_SECRET,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: AccessTokenPayload) {
|
||||
if (!payload) throw new ForbiddenException('Access token malformed');
|
||||
|
||||
const user = await this.usersService.findUserById(payload.sub);
|
||||
|
||||
if (O.isNone(user)) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
const profile = {
|
||||
provider: 'email',
|
||||
id: user.value.email,
|
||||
};
|
||||
|
||||
const providerAccountExists =
|
||||
await this.authService.checkIfProviderAccountExists(user.value, profile);
|
||||
|
||||
if (!providerAccountExists)
|
||||
await this.usersService.createProviderAccount(
|
||||
user.value,
|
||||
null,
|
||||
null,
|
||||
profile,
|
||||
);
|
||||
|
||||
return user.value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user