今天要來寫 api
摟~前情提要一下這次的功能雖然是簡單的 refresh token
的身份驗證,但內容會比較扎實一點,難度比較高,都是一些正常開發會考慮到的寫法,如果讀者有疑問可以到我社群詢問~
這是本次會用到的 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 |
這邊一樣適用 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>
這邊會採用 jsonwebtoken
去幫忙我們做 JWT
的編碼與解密。
> npm i jsonwebtoken && npm i -D @types/jsonwebtoken
簡單介紹怎麼使用 jsonwebtoken
:
sign
: 加密部分會透過 sign method
,第一個參數也就是 payload
這邊你可以放所有你想要放的內容。
privateKey
: 自定義的 key
這邊可以隨意放你的值。algorithm
: 每個 jwt
都會需要指定一種 algorithm
,HS256
算法加密跟解密都是用同一把 key
。callback
: sign
會有 return token
。
jwt.sign({ foo: 'bar' }, privateKey, { algorithm: 'HS256' }, function(err, token) {
console.log(token);
});
verify
: 驗證 sign
的 token
。callback
: 如果 success
會 return 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 始於非對稱加密算法但公鑰通常會是一個對稱加密,所以不能使用公鑰去加密。那這邊會採用 openssl cli
幫我們產生 PS256
算法的 private key
跟 public 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
這邊小提醒私鑰是用加密
,公鑰拿來解密
然後把 print
的 key
放到 .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
這邊是所有 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;
// 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 )
})
}
// 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
}
}
這邊比較特別的是 prisma
因為是 auto typeSafe
的 ORM
所以所有的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)
。
這邊的 Controllers
會是 trpc
中的 procedures
,只是把它拆分出來寫而已,Authentication
有以下四種功能。
這邊我們會透過 cookie
幫我們存 user login
跟 token
的 info
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 user
的 password
。cookies-next
: 可以讓你在 client
跟server
共同使用 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
檢查 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
}
}
}
findUser
沒有就 return notfound
compare password
成功就 signTokens
access_token
、refresh_token
、logged_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
}
}
這邊換取 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
就清除 cookie
跟 session
。
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' }
}
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 })
}
現在有 ctx.user
後就可以添加 middleware
,use
後就得到 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)
然後把所有的 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;
import { router, publicProcedure } from "../createRouter";
import { authRouter } from "./auth.routes";
export const appRouter = router({
auth: authRouter,
})
export type AppRouter = typeof appRouter
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