iT邦幫忙

2023 iThome 鐵人賽

DAY 1
0
Modern Web

一些讓你看來很強的全端- trcp 伴讀系列 第 26

Day-026. 一些讓你看來很強的全端 TRPC 伴讀 -Token authorization (JWT )

  • 分享至 

  • xImage
  •  

今天要來寫 api 摟~前情提要一下這次的功能雖然是簡單的 refresh token 的身份驗證,但內容會比較扎實一點,難度比較高,都是一些正常開發會考慮到的寫法,如果讀者有疑問可以到我社群詢問~

API

這是本次會用到的 api endpoint

Methods Endpoints Descriptions
POST api/trpc/auth.login 登入用戶
POST api/trpc/auth.register 註冊用户
POST api/trpc/auth.logout 登出
GET api/trpc/auth.refresh refresh token

Schema

這邊一樣適用 zod 幫忙完成 validate 的部分,這邊讀者需要先 install 一下~

> npm i zod
import { z } from "zod";

export const createUserSchema = z.object({
  name: z.string({ required_error: 'name is required' }),
  email: z.string({ required_error: 'email is required' }).email('Invalidate email'),
  photo: z.string({ required_error: 'photo is required' }),
  password: z.string({ required_error: 'photo is required' })
    .min(8, { message: 'password must more than 8 characters' })
    .max(32, { message: 'password must less than 32 characters' }),
  passwordConfirm: z.string({ required_error: 'Please confirm your password' })
}).refine(({ password, passwordConfirm }) => password == passwordConfirm, { path: ['passwordConfirm'], message: 'Passwords do not match' })
export type CreateUserSchema = z.infer<typeof createUserSchema>

export const loginUserSchema = z.object({
  email: z.string({ required_error: 'Email is required' }).email(
    'Invalid email'
  ),
  password: z.string({ required_error: 'Password is required' }).min(
    8,
    'password must more than 8 characters'
  ),
});

export type LoginUserSchema = z.infer<typeof loginUserSchema>

Sign and Verify JWTs

這邊會採用 jsonwebtoken 去幫忙我們做 JWT 的編碼與解密。

> npm i jsonwebtoken && npm i -D @types/jsonwebtoken

簡單介紹怎麼使用 jsonwebtoken :

sign : 加密部分會透過 sign method,第一個參數也就是 payload 這邊你可以放所有你想要放的內容。

privateKey : 自定義的 key 這邊可以隨意放你的值。
algorithm : 每個 jwt 都會需要指定一種 algorithmHS256 算法加密跟解密都是用同一把 key
callback : sign 會有 return token

jwt.sign({ foo: 'bar' }, privateKey, { algorithm: 'HS256' }, function(err, token) {
  console.log(token);
});

verify : 驗證 signtoken
callback : 如果 successreturn decoded value 否則 return error

jwt.verify(token, privateKey, function(err, decoded) {
  // err
  // decoded { foo: 'bar' }
});

這邊可以看更多內容

但這邊筆者不打算用 HS256 的算法,因為只要 key 可以拿到,這樣整個 JWT 編碼安全性會有問題,所以這邊打算介紹用另一種算法 PS256 ,這邊簡單比較兩者差異。

  • HS256 : symmetric Key( 對稱加密 ) 用法是一個 key 用在 decode 跟 encode , 不一樣會有 invalid signature。
  • PS256 : Asymmetric Key ( 非對稱加密 ) 用法是一組私鑰加密一組公鑰( symmetric Key )解密,這邊比較特別的是在 PS256 算法中可以用私鑰( Asymmetric Key )當作加密跟解密的鑰匙,但公鑰只能用於解密,原因是 PS256 始於非對稱加密算法但公鑰通常會是一個對稱加密,所以不能使用公鑰去加密。

create key

那這邊會採用 openssl cli 幫我們產生 PS256 算法的 private keypublic key

那因為 PS256 接受的編碼格式是 RSA 所以 -algorithm 記得加上,之後指定生成 private key 的位置。

> openssl genpkey -algorithm RSA -out private_key.pem 

public key 部分則是需要透過剛剛的 private key 來去生成。

> openssl rsa -pubout -in private_key.pem -out public_key.pem 

之後透過 cat 讀取 private_key

> cat private_key.pem | base64

以及 public_key

> cat public_key.pem | base64

這邊小提醒私鑰是用加密,公鑰拿來解密

然後把 printkey 放到 .env 中。

NEXT_PUBLIC_TRPC_ENDPOINT=http://localhost:3000/api/trpc

ACCESS_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT1FJQkFBSkFYTDNua2tLdW13ZUlxbjUrRGVPY2VpcVZZT3huY0pXRkRlZ1IyMHhRMlpDYjBQdU4zbWFxCkNzWnc0anhVR2xmL3JabGZsWldITWJiZ2NJWmxYWjhYYlFJREFRQUJBa0F2TThqRlBJTTZESitXaDBNSk5xdlIKMGhLdGZpVTN2Q0k0YmNHSTZGTE9LSmxnT0RXaGlpaTBrY3g1aTA3ZDdWRlBHMnVWa1U5dnB2OWl4VkMvWEFhQgpBaUVBc2twVEtiNlFpT0xCOGdYMVQxTENXa1UvZEoxWlhUUHRlRHJpQy83RUo3VUNJUUNGS2c2YTROQVFodk5qClcrMFJMQzhseWNFRCsxd0ZvdDR5b0VFeDB5c1QyUUlnSWhzM3gzK045TEcwT2xGTGVTNHl3Y1FIZkk1eFB2UWwKRkYvblNEWW5YaFVDSUZiM0VISzFQeXlMOVlkK0VXU1ZwblRYUGVhTDBaMGNzRi8vcUpRUmhLQ0JBaUVBaXY0MwpDbWI5aUhFNGZ1OFVIeFNkTEszYmNtaEpZdEtLb1o5YnFiZWd5RFE9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t
ACCESS_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZzd0RRWUpLb1pJaHZjTkFRRUJCUUFEU2dBd1J3SkFYTDNua2tLdW13ZUlxbjUrRGVPY2VpcVZZT3huY0pXRgpEZWdSMjB4UTJaQ2IwUHVOM21hcUNzWnc0anhVR2xmL3JabGZsWldITWJiZ2NJWmxYWjhYYlFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0t

REFRESH_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT2dJQkFBSkFhOVFLM2xKNnEzQWFUeDU3M0pBTjIwZG5OUzNsSU9GT2c4RUR2TTZHTDdVT0ZjK2xOUjNQCjlQTnNiOTRvQTNLSVlWOUJlVFdNYmdKRUdJcXFlb21vVVFJREFRQUJBa0E1a042c2lvUUsrckpSQXdsRlczTEsKV25oekg2bHZ3RmxXWmJsRkYwejBNcFdBSlQrNzliQUhhMDh1ZXdHN3ltTE1wdDloZTFibGVuK3M1Yit1dlQ3eApBaUVBd1VEZFlnZC9pK2dZSVArcnJZVWl2dnFwQkdlRWlNVW9NWmxvTElURHR5TUNJUUNPMXE2bjl5dStVZ1VSCnRkWkpDbW1yVWxSbDdHb1kzMDdQZ2lzSXAxU1Qrd0loQUk5SDFsdXVENFRkYWJaZG1SMm56aUVtbTFXbW9uNzkKSGxYWDFZTGNWSGRkQWlCdHFPL0p6LzdyVEhqTDBmaERkeFVOREZYek1mOTZQT1o2ZFFxb3lCNHN4UUloQUl6UApLUStjWHdlTUp5YVB2SEJ0RjVSdnZ2R252WVdyN0plVE13c0t0dnBTCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t
REFRESH_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZzd0RRWUpLb1pJaHZjTkFRRUJCUUFEU2dBd1J3SkFhOVFLM2xKNnEzQWFUeDU3M0pBTjIwZG5OUzNsSU9GTwpnOEVEdk02R0w3VU9GYytsTlIzUDlQTnNiOTRvQTNLSVlWOUJlVFdNYmdKRUdJcXFlb21vVVFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0t


config

src/server/config/default.ts

這邊是所有 api 中會用到的 config


const customConfig: {
  port: number;
  accessTokenExpiresIn: number;
  refreshTokenExpiresIn: number;
  origin: string;
  dbUri: string;
  accessTokenPrivateKey: string;
  refreshTokenPrivateKey: string;
  accessTokenPublicKey: string;
  refreshTokenPublicKey: string;
  redisCacheExpiresIn: number;
} = {
  port: 8000,
  accessTokenExpiresIn: 15,
  refreshTokenExpiresIn: 60,
  origin: 'http://localhost:3000',
  redisCacheExpiresIn: 60,

  dbUri: process.env.DATABASE_URL as string,
  accessTokenPrivateKey: process.env.ACCESS_TOKEN_PRIVATE_KEY as string,
  accessTokenPublicKey: process.env.ACCESS_TOKEN_PUBLIC_KEY as string,
  refreshTokenPrivateKey: process.env.REFRESH_TOKEN_PRIVATE_KEY as string,
  refreshTokenPublicKey: process.env.REFRESH_TOKEN_PUBLIC_KEY as string,
};

export default customConfig;

Sign the JWT Tokens

src/server/utils/jwt.ts

// src/server/utils/jwt.ts
import jwt, { SignOptions } from 'jsonwebtoken';
import customConfig from '../config/default';

export const signJwt = (
  payload: Object,
  key: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey',
  options?: SignOptions
) => {
  const priviteKey = Buffer.from(customConfig[key], 'base64')
  return jwt.sign(payload, priviteKey, {
    ...options,
    algorithm: 'PS256' // PS256 用於驗證 RSA 算法的密鑰,通常這類演算法的場景是一組私鑰 ( sign ) 和一組公鑰 ( verify )
  })
}

Verify the JWT Tokens

src/server/utils/jwt.ts

// src/server/utils/jwt.ts
export const verifyJwt = <T>(
  token: string,
  key: 'accessTokenPublicKey' | 'refreshTokenPublicKey'
): T | null => {
  try {
    const publicKey = Buffer.from(customConfig[key], 'base64') // 將 base64 轉乘 ascii 字元
    return jwt.verify(token, publicKey) as T
  } catch (e) {
    console.error(e)
    return null
  }
}

services

src/server/services/user.service.ts

這邊比較特別的是 prisma 因為是 auto typeSafeORM 所以所有的type 可以透過 Prisma 去找,例如 WhereUserInput 等等 。

import { Prisma, User } from "@prisma/client"
import { redisClient } from "../utils/connectRedis"
import { json } from "stream/consumers"
import { customConfig } from "../config/default"
import { signJwt } from "../utils/jwt"

export const createUser = async (input: Prisma.UserCreateInput) => {
  return prisma?.user.create({ data: input })
}

export const findUser = async (
  where: Prisma.UserWhereInput,
  select?: Prisma.UserSelect
) => {
  return prisma?.user.findFirst({
    where,
    select
  })
}
export const findUniqueUser = async (
  where: Prisma.UserWhereUniqueInput,
  select?: Prisma.UserSelect
) => {
  return prisma?.user.findUnique({
    where,
    select
  })
}

export const updateUser = async (
  where: Prisma.UserWhereUniqueInput,
  data: Prisma.UserUpdateInput,
  select?: Prisma.UserSelect
) => {
  return prisma?.user.update({
    where,
    data,
    select
  })
}

export const deleteUser = async (
  where: Prisma.UserWhereUniqueInput,
) => {
  return prisma?.user.delete({
    where,
  })
}

export const signTokens = async (
  user: User
) => {
  // Create session
  redisClient.set(`${user.id}`, JSON.stringify(user), {
    EX: customConfig.redisCacheExpiresIn * 60
  })

  // create accessToken and refreshToken
  const access_token = signJwt({ sub: user.id }, 'accessTokenPrivateKey', {
    expiresIn: customConfig.accessTokenExpiresIn * 60
  })
  const refresh_token = signJwt({ sub: user.id }, 'refreshTokenPrivateKey', {
    expiresIn: customConfig.refreshTokenExpiresIn * 60
  })
  return { refresh_token, access_token }
}

每次signTokens 都會將 user info 存到 session (redis)

Authentication Controllers

這邊的 Controllers 會是 trpc 中的 procedures ,只是把它拆分出來寫而已,Authentication 有以下四種功能。

  • add new user
  • Refresh access token
  • sing in the register
  • logout

src/server/controllers/auth.controller.ts

這邊我們會透過 cookie 幫我們存 user logintokeninfo

import { TRPCError } from '@trpc/server';
import bcrypt from 'bcryptjs';
import { OptionsType } from 'cookies-next/lib/types';
import { getCookie, setCookie } from 'cookies-next';
import customConfig from '../config/default';
import { Context } from '../createContext';
import { CreateUserInput, LoginUserInput } from '../schema/user.schema';
import {
  createUser,
  findUniqueUser,
  findUser,
  signTokens,
} from '../services/user.service';
import redisClient from '../utils/connectRedis';
import { signJwt, verifyJwt } from '../utils/jwt';

// [...] Cookie options
const cookieOptions: OptionsType = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
};

const accessTokenCookieOptions: OptionsType = {
  ...cookieOptions,
  expires: new Date(Date.now() + customConfig.accessTokenExpiresIn * 60 * 1000),
};

const refreshTokenCookieOptions: OptionsType = {
  ...cookieOptions,
  expires: new Date(
    Date.now() + customConfig.refreshTokenExpiresIn * 60 * 1000
  ),
};

bcryptjs : 要 hashed userpassword
cookies-next : 可以讓你在 clientserver 共同使用 cookie

> npm i bcryptjs cookies-next && npm i -D @types/bcryptjs
import {  setCookie } from 'cookies-next';
setCookie('key', 'value'); // - client side
setCookie('key', 'value', { req, res }); // - server side

Register User tRPC Controller

src/server/controllers/auth.controller.ts

檢查 duplicateUser 之後 hash password

export const registerHandler = async ({ input }: {
  input: CreateUserSchema
}) => {
  const duplicateUser = await findUser({ email: input.email })
  if (duplicateUser) {
    throw new TRPCError({
      code: 'CONFLICT',
      message: 'Email has Already exists'
    })
  }
  const hashPassword = await bcrypt.hash(input.password, 12)
  const user = await createUser({
    ...input,
    hashPassword,
    provider: 'local'
  })
  return {
    status: 'success',
    data: {
      user
    }
  }

}

Login tRPC Controller

src/server/controllers/auth.controller.ts

  • findUser 沒有就 return notfound
  • compare password 成功就 signTokens
  • access_tokenrefresh_tokenlogged_in 結果存到 cookie
export const loginHandler = async ({ input, ctx }: {
  input: LoginUserSchema,
  ctx: Context
}) => {
  const { req, res } = ctx
  const user = await findUser({ email: input.email })
  if (!user) {
    return new TRPCError({
      code: 'BAD_REQUEST',
      message: 'user not found'
    })
  }
  const isValidatePassword = await bcrypt.compare(user.hashPassword as string, input.password)
  if (!isValidatePassword) {
    return new TRPCError({
      code: 'BAD_REQUEST',
      message: 'Invalid password'
    })
  }
  const { access_token, refresh_token } = await signTokens(user)



  setCookie('access_token', access_token, {
    req,
    res,
    ...accessTokenCookieOptions,
  })
  setCookie('refresh_token', refresh_token, {
    req,
    res,
    ...refreshTokenCookieOptions,
  })
  setCookie('logged_in', 'true', {
    req,
    res,
    ...accessTokenCookieOptions,
    httpOnly: false
  })
  return {
    status: 'success',
    access_token
  }
}

Refresh Access_token tRPC Controller

src/server/controllers/auth.controller.ts

這邊換取 access_token方式是透過 refresh_token 換取。

export const refreshAccessTokenHandler = async ({ ctx }: {
  ctx: Context
}) => {
  const { req, res } = ctx
  const refetch_token = getCookie('refresh_token', { req, res })
  let message = 'Could not refresh access token'
  if (!refetch_token) {
    throw new TRPCError({ code: 'FORBIDDEN', message })
  }
  const decoded = verifyJwt<{ sub: string }>(refetch_token, 'refreshTokenPublicKey')
  if (!decoded) {
    throw new TRPCError({ code: 'FORBIDDEN', message })
  }

  const session = await redisClient.get(decoded.sub)
  if (!session) {
    throw new TRPCError({ code: 'FORBIDDEN', message })
  }
  const user = await findUniqueUser({ email: JSON.parse(session).id }, {})
  if (!user) {
    throw new TRPCError({ code: 'FORBIDDEN', message })
  }
  const { access_token, refresh_token } = await signTokens(user)
  setCookie('access_token', access_token, {
    req,
    res,
    ...accessTokenCookieOptions
  })
  setCookie('logged_in', 'true', {
    req,
    res,
    ...accessTokenCookieOptions,
    httpOnly: false
  })

  return {
    status: 'success',
    access_token
  }
}

logout tRPC Controller

src/server/controllers/auth.controller.ts

logout 就清除 cookiesession

const logout = async ({ ctx }: { ctx: Context }) => {
  const { req, res } = ctx
  setCookie('access_token', '', { req, res, maxAge: -1 })
  setCookie('refresh_token', '', { req, res, maxAge: -1 })
  setCookie('logged_in', '', { req, res, maxAge: -1 })
}

export const logoutHandler = async ({ ctx }: { ctx: Context }) => {
  const { user } = ctx
  await redisClient.del(String(user?.id))
  logout({ ctx })
  return { status: 'success' }
}

tRPC Authentication Guard

src/server/middleware/deserializeUser.ts

import { CreateNextContextOptions } from "@trpc/server/adapters/next"
import { verifyJwt } from "../utils/jwt"
import { redisClient } from "../utils/connectRedis"
import { findUniqueUser } from "../services/user.service"
import { TRPCError } from "@trpc/server"
import { prisma } from '../utils/prisma'
export const deserializeUser = async (
  opt: CreateNextContextOptions
) => {
  try {

    const { req, res } = opt
    let access_token
    if (req.headers.authorization &&
      req.headers.authorization.startsWith('Bearer')
    ) {
      access_token = req.headers.authorization
    }
    const notAuthenticated = {
      req,
      res,
      user: null,
      prisma
    };
    if (!access_token) return notAuthenticated
    const decode = verifyJwt<{ sub: string }>(access_token, 'accessTokenPublicKey')
    if (!decode) return notAuthenticated
    const session = await redisClient.get(decode.sub)
    if (!session) return notAuthenticated
    const user = await findUniqueUser({ id: JSON.parse(session).id })
    if (!user) return notAuthenticated
    return {
      req,
      res,
      prisma,
      user: { ...user, id: user.id }
    }
  } catch (e: any) {
    throw new TRPCError({
      code: 'INTERNAL_SERVER_ERROR',
      message: e.message
    })
  }
}

deserializeUser 放到 createContext

export const createContext = (opt: CreateNextContextOptions) => {
  const { req, res } = opt
  return deserializeUser({ req, res })
}

trpc endpoint

src/server/createRouter.ts

現在有 ctx.user 後就可以添加 middlewareuse 後就得到 protectedProcedure拉~

import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { Context } from "./createContext";

export const t = initTRPC.context<Context>().create({
  transformer: superjson,
});

const isAuthed = t.middleware(({ next, ctx }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in to access this resource",
    });
  }
  return next();
});

export const protectedProcedure = t.procedure.use(isAuthed)

src/server/routers/auth.routes.ts

然後把所有的 controller 寫到 routes 中。

import {
  loginHandler,
  logoutHandler,
  refreshAccessTokenHandler,
  registerHandler,
} from "../controllers/auth.controller";
import { t } from "../createRouter";
import { createUserSchema, loginUserSchema } from "../schema/user.schema";

const authRouter = t.router({
  registerUser: t.procedure
    .input(createUserSchema)
    .mutation(({ input }) => registerHandler({ input })),
  loginUser: t.procedure
    .input(loginUserSchema)
    .mutation(({ input, ctx }) => loginHandler({ input, ctx })),
  logoutUser: t.procedure.mutation(({ ctx }) => logoutHandler({ ctx })),
  refreshAccessToken: t.procedure.query(({ ctx }) =>
    refreshAccessTokenHandler({ ctx })
  ),
});

export default authRouter;

src/server/routers/app.routes.ts

import { router, publicProcedure } from "../createRouter";
import { authRouter } from "./auth.routes";
export const appRouter = router({
  auth: authRouter,
})


export type AppRouter = typeof appRouter

src/pages/api/trpc[trpc].ts


import * as trpcNext from "@trpc/server/adapters/next";
import { appRouter } from "~/server/routers/app.routes";
import { createContext } from "~/server/createContext";

export default trpcNext.createNextApiHandler({
  router: appRouter,
  createContext,
});

相關連結

https://github.com/Danny101201/refetch-token/tree/main

✅ 前端社群 :
https://lihi3.cc/kBe0Y


上一篇
Day-025. 一些讓你看來很強的全端 TRPC 伴讀 -Token authorization ( Init )
下一篇
Day-027. 一些讓你看來很強的全端 TRPC 伴讀 - zustand & cloundinary
系列文
一些讓你看來很強的全端- trcp 伴讀30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言