iT邦幫忙

2023 iThome 鐵人賽

DAY 27
0
Modern Web

由前向後,從前端邁向全端系列 第 27

27.【從前端到全端,Nextjs+Nestjs】加入authenication (二)

  • 分享至 

  • xImage
  •  

文章重點

  • 創建Auth API
  • 使用passport jwt創建authenication

本文

現在我們要在我們的User上實現authenication。

首先,在common新建一個guard

///// apps\iron-ecommerce-server\src\common\auth\gql-auth.guard.ts

import { ExecutionContext, Injectable } from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class GqlAuthGuard extends AuthGuard("jwt") {
	getRequest(context: ExecutionContext) {
		const ctx = GqlExecutionContext.create(context);
		return ctx.getContext().req;
	}
}

接著設定一下環境變數,打開.env加入

JWT_ACCESS_SECRET=nestjsPrismaAccessSecret
JWT_REFRESH_SECRET=nestjsPrismaRefreshSecret

以及創建新api來處理auth

///// apps\iron-ecommerce-server\src\api\auth\jwt-strategy.ts
import { AuthService } from "./auth.service";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { User } from "@prisma/client";

import { ExtractJwt, Strategy } from "passport-jwt";

export interface JwtDto {
	userId: string;
	// Issued at
	iat: number;
	exp: number;
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
	constructor(private readonly authService: AuthService, readonly configService: ConfigService) {
		super({
			jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
			secretOrKey: configService.get("JWT_ACCESS_SECRET")
		});
	}

	async validate(payload: JwtDto): Promise<User> {
		const user = await this.authService.validateUser(payload.userId);
		if (!user) {
			throw new UnauthorizedException();
		}
		return user;
	}
}

///// apps\iron-ecommerce-server\src\api\auth\auth.model.ts
import { Field, ObjectType } from "@nestjs/graphql";
import { User } from "@prisma/client";

import { GraphQLJWT } from "graphql-scalars";

@ObjectType()
export class Token {
	@Field(() => GraphQLJWT, { description: "JWT access token" })
	accessToken: string;

	@Field(() => GraphQLJWT, { description: "JWT refresh token" })
	refreshToken: string;
}

@ObjectType()
export class Auth extends Token {
	user: User;
}


///// apps\iron-ecommerce-server\src\api\auth\auth.input.ts
import { ArgsType, Field, InputType } from "@nestjs/graphql";

import { IsEmail, IsJWT, IsNotEmpty, MinLength } from "class-validator";
import { GraphQLJWT } from "graphql-scalars";

@InputType()
export class LoginInput {
	@Field()
	@IsEmail()
	email: string;

	@Field()
	@IsNotEmpty()
	@MinLength(8)
	password: string;
}

@ArgsType()
export class RefreshTokenInput {
	@IsNotEmpty()
	@IsJWT()
	@Field(() => GraphQLJWT)
	token: string;
}

@InputType()
export class SignupInput {
	@Field()
	@IsEmail()
	email: string;

	@Field()
	@IsNotEmpty()
	@MinLength(8)
	password: string;

	@Field()
	@IsNotEmpty()
	@MinLength(4)
	username: string;

	@Field({ nullable: true })
	firstname?: string;

	@Field({ nullable: true })
	lastname?: string;
}

///// apps\iron-ecommerce-server\src\api\auth\auth.service.ts
import { SecurityConfig } from "../../common/configs/config.interface";
import { SignupInput } from "./auth.input";
import { Token } from "./auth.model";
import { PasswordService } from "./password.service";
import {
	BadRequestException,
	ConflictException,
	Injectable,
	NotFoundException,
	UnauthorizedException
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { Prisma, User } from "@prisma/client";

import { PrismaService } from "nestjs-prisma";

@Injectable()
export class AuthService {
	constructor(
		private readonly jwtService: JwtService,
		private readonly prisma: PrismaService,
		private readonly passwordService: PasswordService,
		private readonly configService: ConfigService
	) {}

	async createUser(payload: SignupInput): Promise<Token> {
		const hashedPassword = await this.passwordService.hashPassword(payload.password);

		try {
			const user = await this.prisma.user.create({
				data: {
					...payload,
					firstname: payload.username,
					password: hashedPassword,
					role: "USER"
				}
			});

			return this.generateTokens({
				userId: user.id
			});
		} catch (e) {
			if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
				throw new ConflictException(`Email ${payload.email} already used.`);
			} else {
				throw new Error(e);
			}
		}
	}

	async login(email: string, password: string): Promise<Token> {
		const user = await this.prisma.user.findUnique({ where: { email } });

		if (!user) {
			throw new NotFoundException(`No user found for email: ${email}`);
		}

		const passwordValid = await this.passwordService.validatePassword(password, user.password);

		if (!passwordValid) {
			throw new BadRequestException("Invalid password");
		}

		return this.generateTokens({
			userId: user.id
		});
	}

	validateUser(userId: string): Promise<User> {
		return this.prisma.user.findUnique({ where: { id: userId } });
	}

	getUserFromToken(token: string): Promise<User> {
		const id = this.jwtService.decode(token)["userId"];
		return this.prisma.user.findUnique({ where: { id } });
	}

	generateTokens(payload: { userId: string }): Token {
		return {
			accessToken: this.generateAccessToken(payload),
			refreshToken: this.generateRefreshToken(payload)
		};
	}

	private generateAccessToken(payload: { userId: string }): string {
		return this.jwtService.sign(payload);
	}

	private generateRefreshToken(payload: { userId: string }): string {
		const securityConfig = this.configService.get<SecurityConfig>("security");
		return this.jwtService.sign(payload, {
			secret: this.configService.get("JWT_REFRESH_SECRET"),
			expiresIn: securityConfig.refreshIn
		});
	}

	refreshToken(token: string) {
		try {
			const { userId } = this.jwtService.verify(token, {
				secret: this.configService.get("JWT_REFRESH_SECRET")
			});

			return this.generateTokens({
				userId
			});
		} catch (e) {
			throw new UnauthorizedException();
		}
	}
}



///// apps\iron-ecommerce-server\src\api\auth\auth.resolver.ts
import { User } from "../users/users.model";
import { LoginInput, RefreshTokenInput, SignupInput } from "./auth.input";
import { Auth, Token } from "./auth.model";
import { AuthService } from "./auth.service";
import { Args, Mutation, Parent, ResolveField, Resolver } from "@nestjs/graphql";

@Resolver(() => Auth)
export class AuthResolver {
	constructor(private readonly auth: AuthService) {}

	@Mutation(() => Auth)
	async signup(@Args("data") data: SignupInput) {
		data.email = data.email.toLowerCase();
		const { accessToken, refreshToken } = await this.auth.createUser(data);
		return {
			accessToken,
			refreshToken
		};
	}

	@Mutation(() => Auth)
	async login(@Args("data") { email, password }: LoginInput) {
		const { accessToken, refreshToken } = await this.auth.login(email.toLowerCase(), password);

		return {
			accessToken,
			refreshToken
		};
	}

	@Mutation(() => Token)
	async refreshToken(@Args() { token }: RefreshTokenInput) {
		return this.auth.refreshToken(token);
	}

	@ResolveField("user", () => User)
	async user(@Parent() auth: Auth) {
		return await this.auth.getUserFromToken(auth.accessToken);
	}
}


///// apps\iron-ecommerce-server\src\api\auth\auth.module.ts
import { GqlAuthGuard } from "../../common/auth/gql-auth.guard";
import { SecurityConfig } from "../../common/configs/config.interface";
import { AuthResolver } from "./auth.resolver";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt-strategy";
import { PasswordService } from "./password.service";
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";

@Module({
	imports: [
		PassportModule.register({ defaultStrategy: "jwt" }),
		JwtModule.registerAsync({
			useFactory: async (configService: ConfigService) => {
				const securityConfig = configService.get<SecurityConfig>("security");
				return {
					secret: configService.get<string>("JWT_ACCESS_SECRET"),
					signOptions: {
						expiresIn: securityConfig.expiresIn
					}
				};
			},
			inject: [ConfigService]
		})
	],
	providers: [AuthService, AuthResolver, JwtStrategy, GqlAuthGuard, PasswordService],
	exports: [GqlAuthGuard]
})
export class AuthModule {}

並將AuthModule加入到App Module裡面

///// apps\iron-ecommerce-server\src\app\app.module.ts
import { AuthModule } from "../api/auth/auth.module";
import { ProductsModule } from "../api/products/products.module";
import { UsersModule } from "../api/users/users.module";
import config from "../common/configs/config";
import { GraphQLSetupModule } from "../graphql/graphql-setup.module";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";

import { PrismaModule } from "nestjs-prisma";

@Module({
	imports: [
		ConfigModule.forRoot({ isGlobal: true, load: [config] }),
		PrismaModule.forRoot({
			isGlobal: true,
			prismaServiceOptions: {
				middlewares: []
			}
		}),
		GraphQLSetupModule,
		ProductsModule,
		UsersModule,
		AuthModule
	],
	controllers: [AppController],
	providers: [AppService]
})
export class AppModule {}

接下來我們加入guard。

///// apps\iron-ecommerce-server\src\api\users\users.resolver.ts

import { GqlAuthGuard } from "../../common/auth/gql-auth.guard";
import { User } from "./users.model";
import { UsersService } from "./users.service";
import { UseGuards } from "@nestjs/common";
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";

@Resolver(() => User)
export class UsersResolver {
	constructor(private usersService: UsersService) {}

	@Query(() => User)
	async me(@Args("userId") userId: string): Promise<User> {
		return this.usersService.findUserById(userId);
	}

	@UseGuards(GqlAuthGuard)
	@Mutation(() => User)
	async updateUser(
		@Args("userId") userId: string,
		@Args("newUserData") newUserData: { firstname?: string; lastname?: string }
	) {
		return this.usersService.updateUser(userId, newUserData);
	}
}

接下來進行測試

mutation {
  signup(data: {
    email: "test@test.com"
    username: "tester"
    password: "test"
  }) {
    user {
      username
      email
    }
  }
}

//// response

{
  "data": {
    "signup": {
      "user": {
        "username": "tester",
        "email": "test@test.com"
      }
    }
  }
}

能看到創建了user
https://ithelp.ithome.com.tw/upload/images/20231012/20108931jFShHnSqYU.png

現在我們進行登入

mutation {
	login(data: {
    email: "test@test.com"
    password: "test"
  }) {
    user {
      username
      role
    }
    refreshToken
    accessToken
  }
}

///// response
{
  "data": {
    "login": {
      "user": {
        "username": "tester",
        "role": "USER"
      },
      "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIyOThkMDkyNy00NGEzLTQ2OTAtYjcxZC0yMmFhZDE5YjRlNDMiLCJpYXQiOjE2OTcxMjU0NDAsImV4cCI6MTY5NzczMDI0MH0.hGFRNGL89TKhibnxQtUwuRgYbqZDFb4Os8qTVaPJ5Ik",
      "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIyOThkMDkyNy00NGEzLTQ2OTAtYjcxZC0yMmFhZDE5YjRlNDMiLCJpYXQiOjE2OTcxMjU0NDAsImV4cCI6MTY5NzEyNTU2MH0.TD2tq7LhMW-uSGfBFRoDOOWufnEY80mlVA20eaqSuUM"
    }
  }
}

現在我們進行測試,試著不使用accessToken來執行updateUser

mutation {
  updateUser(
    userId: "298d0927-44a3-4690-b71d-22aad19b4e43" 
    newUserData: {
    	username: "updated"
  	}) {
    username
  }
}

https://ithelp.ithome.com.tw/upload/images/20231012/20108931JgCONlVNvP.png
接下來使用

mutation {
  updateUser(
    userId: "298d0927-44a3-4690-b71d-22aad19b4e43" 
    newUserData: {
    	username: "updated"
  	}) {
    username
  }
}

///// header
{
  "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIyOThkMDkyNy00NGEzLTQ2OTAtYjcxZC0yMmFhZDE5YjRlNDMiLCJpYXQiOjE2OTcxMjU0NDAsImV4cCI6MTY5NzEyNTU2MH0.TD2tq7LhMW-uSGfBFRoDOOWufnEY80mlVA20eaqSuUM"
}

https://ithelp.ithome.com.tw/upload/images/20231012/20108931Oh8nhaXpjk.png

我們能看到可以更新了,現在打開SQLTools觀看
https://ithelp.ithome.com.tw/upload/images/20231012/20108931HP9P5v4nCg.png


總結


上一篇
26.【從前端到全端,Nextjs+Nestjs】加入authenication(一)
下一篇
28.【從前端到全端,Nextjs+Nestjs】將Nextjs的GraphQL資料改成使用後端資料
系列文
由前向後,從前端邁向全端30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言