import { BadRequestException, ForbiddenException, UseGuards, } from '@nestjs/common'; import { Args, Context, Field, Mutation, ObjectType, Parent, ResolveField, Resolver, } from '@nestjs/graphql'; import type { Request } from 'express'; import { nanoid } from 'nanoid'; import { Config } from '../../config'; import { SessionService } from '../../session'; import { CloudThrottlerGuard, Throttle } from '../../throttler'; import { UserType } from '../users'; import { Auth, CurrentUser } from './guard'; import { AuthService } from './service'; @ObjectType() export class TokenType { @Field() token!: string; @Field() refresh!: string; @Field({ nullable: true }) sessionToken?: string; } /** * Auth resolver * Token rate limit: 20 req/m * Sign up/in rate limit: 10 req/m * Other rate limit: 5 req/m */ @UseGuards(CloudThrottlerGuard) @Resolver(() => UserType) export class AuthResolver { constructor( private readonly config: Config, private readonly auth: AuthService, private readonly session: SessionService ) {} @Throttle({ default: { limit: 20, ttl: 60, }, }) @ResolveField(() => TokenType) async token( @Context() ctx: { req: Request }, @CurrentUser() currentUser: UserType, @Parent() user: UserType ) { if (user.id !== currentUser.id) { throw new BadRequestException('Invalid user'); } let sessionToken: string | undefined; // only return session if the request is from the same origin & path == /open-app if ( ctx.req.headers.referer && ctx.req.headers.host && new URL(ctx.req.headers.referer).pathname.startsWith('/open-app') && ctx.req.headers.host === new URL(this.config.origin).host ) { const cookiePrefix = this.config.node.prod ? '__Secure-' : ''; const sessionCookieName = `${cookiePrefix}next-auth.session-token`; sessionToken = ctx.req.cookies?.[sessionCookieName]; } return { sessionToken, token: this.auth.sign(user), refresh: this.auth.refresh(user), }; } @Throttle({ default: { limit: 10, ttl: 60, }, }) @Mutation(() => UserType) async signUp( @Context() ctx: { req: Request }, @Args('name') name: string, @Args('email') email: string, @Args('password') password: string ) { const user = await this.auth.signUp(name, email, password); ctx.req.user = user; return user; } @Throttle({ default: { limit: 10, ttl: 60, }, }) @Mutation(() => UserType) async signIn( @Context() ctx: { req: Request }, @Args('email') email: string, @Args('password') password: string ) { const user = await this.auth.signIn(email, password); ctx.req.user = user; return user; } @Throttle({ default: { limit: 5, ttl: 60, }, }) @Mutation(() => UserType) @Auth() async changePassword( @CurrentUser() user: UserType, @Args('token') token: string, @Args('newPassword') newPassword: string ) { const id = await this.session.get(token); if (!user.emailVerified) { throw new ForbiddenException('Please verify the email first'); } if ( !id || (id !== user.id && // change password after sign in with email link // we only create user account after user sign in with email link id !== user.email) ) { throw new ForbiddenException('Invalid token'); } await this.auth.changePassword(user.email, newPassword); await this.session.delete(token); return user; } @Throttle({ default: { limit: 5, ttl: 60, }, }) @Mutation(() => UserType) @Auth() async changeEmail( @CurrentUser() user: UserType, @Args('token') token: string ) { // email has set token in `sendVerifyChangeEmail` const [id, email] = (await this.session.get(token)).split(','); if (!id || id !== user.id || !email) { throw new ForbiddenException('Invalid token'); } await this.auth.changeEmail(id, email); await this.session.delete(token); await this.auth.sendNotificationChangeEmail(email); return user; } @Throttle({ default: { limit: 5, ttl: 60, }, }) @Mutation(() => Boolean) @Auth() async sendChangePasswordEmail( @CurrentUser() user: UserType, @Args('email') email: string, @Args('callbackUrl') callbackUrl: string ) { const token = nanoid(); await this.session.set(token, user.id); const url = new URL(callbackUrl, this.config.baseUrl); url.searchParams.set('token', token); const res = await this.auth.sendChangePasswordEmail(email, url.toString()); return !res.rejected.length; } @Throttle({ default: { limit: 5, ttl: 60, }, }) @Mutation(() => Boolean) @Auth() async sendSetPasswordEmail( @CurrentUser() user: UserType, @Args('email') email: string, @Args('callbackUrl') callbackUrl: string ) { const token = nanoid(); await this.session.set(token, user.id); const url = new URL(callbackUrl, this.config.baseUrl); url.searchParams.set('token', token); const res = await this.auth.sendSetPasswordEmail(email, url.toString()); return !res.rejected.length; } // The change email step is: // 1. send email to primitive email `sendChangeEmail` // 2. user open change email page from email // 3. send verify email to new email `sendVerifyChangeEmail` // 4. user open confirm email page from new email // 5. user click confirm button // 6. send notification email @Throttle({ default: { limit: 5, ttl: 60, }, }) @Mutation(() => Boolean) @Auth() async sendChangeEmail( @CurrentUser() user: UserType, @Args('email') email: string, @Args('callbackUrl') callbackUrl: string ) { const token = nanoid(); await this.session.set(token, user.id); const url = new URL(callbackUrl, this.config.baseUrl); url.searchParams.set('token', token); const res = await this.auth.sendChangeEmail(email, url.toString()); return !res.rejected.length; } @Throttle({ default: { limit: 5, ttl: 60, }, }) @Mutation(() => Boolean) @Auth() async sendVerifyChangeEmail( @CurrentUser() user: UserType, @Args('token') token: string, @Args('email') email: string, @Args('callbackUrl') callbackUrl: string ) { const id = await this.session.get(token); if (!id || id !== user.id) { throw new ForbiddenException('Invalid token'); } const hasRegistered = await this.auth.getUserByEmail(email); if (hasRegistered) { throw new BadRequestException(`Invalid user email`); } const withEmailToken = nanoid(); await this.session.set(withEmailToken, `${user.id},${email}`); const url = new URL(callbackUrl, this.config.baseUrl); url.searchParams.set('token', withEmailToken); const res = await this.auth.sendVerifyChangeEmail(email, url.toString()); await this.session.delete(token); return !res.rejected.length; } }