x1ongzhu il y a 1 an
Parent
commit
63ab5a8a6b

+ 2 - 0
.env.local

@@ -0,0 +1,2 @@
+JWT_SECRET=RfR*P!P^9@suU&
+JWT_EXPIRATION=2592000

+ 4 - 1
bindings.d.ts

@@ -1,7 +1,10 @@
 type Environment = {
     Bindings: {
         ENV: string
-        DB: D1Database
+        DB: D1Database,
+        JWT_SECRET: string
+        JWT_EXPIRATION: number
+        REGISTRATION_ENABLED: boolean
     }
     Variables: {}
 }

+ 8 - 7
migrations/0001_init.sql

@@ -1,9 +1,10 @@
 -- Migration number: 0001 	 2024-03-24T17:57:51.379Z
 create table if not exists user (
-    id integer primary key autoincrement,
-    name varchar(255),
-    email varchar(255),
-    password varchar(255),
-    created_at timestamp not null default current_timestamp
-);
-
+    `id` integer primary key autoincrement,
+    `created_at` timestamp not null default current_timestamp,
+    `name` varchar(255),
+    `email` varchar(255) unique not null,
+    `password` varchar(255),
+    `role` varchar(255) not null default 'user',
+    `metadata` json
+);

+ 3 - 0
package.json

@@ -6,12 +6,15 @@
   },
   "dependencies": {
     "@hono/swagger-ui": "^0.2.1",
+    "bcryptjs": "^2.4.3",
+    "date-fns": "^3.6.0",
     "hono": "^4.1.3",
     "kysely": "^0.27.3",
     "kysely-d1": "^0.3.0"
   },
   "devDependencies": {
     "@cloudflare/workers-types": "^4.20240208.0",
+    "@types/bcryptjs": "^2.4.6",
     "wrangler": "^3.32.0"
   }
 }

+ 28 - 3
src/index.ts

@@ -1,13 +1,38 @@
-import { Hono } from "hono"
+import { Context, Hono, Next } from "hono"
 import UserRoute from "./routes/users-route"
-import { swaggerUI } from "@hono/swagger-ui"
+import AuthRoute from "./routes/auth-route"
 import { cors } from "hono/cors"
+import { jwt, decode, sign, verify } from "hono/jwt"
+import { logger } from "hono/logger"
 import { showRoutes } from "hono/dev"
+import { HTTPException } from "hono/http-exception"
 
 const app = new Hono<Environment>()
 
+const jwtMiddleware = (c: Context, next: Next) => {
+    return jwt({
+        secret: c.env.JWT_SECRET
+    })(c, next)
+}
+
+app.use(logger())
 app.use("/*", cors())
+app.use("/users/*", jwtMiddleware)
 
+app.route("/auth", AuthRoute)
 app.route("/users", UserRoute)
-showRoutes(app)
+
+app.onError(async (err, c) => {
+    if (err instanceof HTTPException) {
+        console.log(err)
+        return c.json({ message: err.message }, err.getResponse().status as any)
+    }
+    const env = c.env.ENV || "production"
+    const response = {
+        message: err.message,
+        ...(env === "development" && { stack: err.stack })
+    }
+    delete c.error
+    return c.json(response, 500)
+})
 export default app

+ 59 - 0
src/routes/auth-route.ts

@@ -0,0 +1,59 @@
+import { Hono } from "hono"
+import { getDB } from "../database"
+import * as authValidation from "../validations/auth.validation"
+import { generateToken } from "../services/token.service"
+import bcrypt from "bcryptjs"
+import { env } from "hono/adapter"
+import { HTTPException } from "hono/http-exception"
+
+const route = new Hono<Environment>()
+
+route.post("/register", async c => {
+    if (!c.env.REGISTRATION_ENABLED)
+        throw new HTTPException(403, { message: "Registration is disabled" })
+    const { email, password, name } = await authValidation.register.parseAsync(
+        await c.req.json()
+    )
+    const db = getDB(c)
+    const user = await db
+        .insertInto("user")
+        .values({
+            email,
+            password,
+            name,
+            role: "user"
+        })
+        .returningAll()
+        .executeTakeFirstOrThrow()
+    const token = await generateToken(user, c)
+    return c.json({
+        user,
+        token
+    })
+})
+
+route.post("/login", async c => {
+    const { email, password } = authValidation.login.parse(await c.req.json())
+    const db = getDB(c)
+    const user = await db
+        .selectFrom("user")
+        .where("email", "=", email)
+        .selectAll()
+        .executeTakeFirstOrThrow()
+    const isPasswordMatch = await bcrypt.compare(password, user.password || "")
+    if (!isPasswordMatch) {
+        return c.json(
+            {
+                message: "Invalid email or password"
+            },
+            401
+        )
+    }
+    const token = await generateToken(user, c)
+    return c.json({
+        user,
+        token
+    })
+})
+
+export default route

+ 0 - 12
src/routes/users-route.ts

@@ -31,16 +31,4 @@ route.get("/", async c => {
     return c.json(await db.selectFrom("user").selectAll().execute())
 })
 
-route.post("/", async c => {
-    const db = getDB(c)
-    const user = await db
-        .insertInto("user")
-        .values({
-            name: "John Doe"
-        })
-        .returningAll()
-        .executeTakeFirstOrThrow()
-    return c.json(user)
-})
-
 export default route

+ 16 - 0
src/services/token.service.ts

@@ -0,0 +1,16 @@
+import { decode, sign, verify } from "hono/jwt"
+import { User } from "../types"
+import { Context } from "hono"
+import { getTime, fromUnixTime, addSeconds } from "date-fns"
+
+export const generateToken = async (user: User, c: Context<Environment>) => {
+    return await sign(
+        {
+            sub: user.id,
+            exp: getTime(addSeconds(new Date(), c.env.JWT_EXPIRATION)) / 1000,
+            iat: getTime(new Date()) / 1000,
+            role: user.role
+        },
+        c.env.JWT_SECRET
+    )
+}

+ 1 - 0
src/types.ts

@@ -17,6 +17,7 @@ export interface UserTable {
     name: string | null
     email: string | null
     password: string | null
+    role: "admin" | "user"
     metadata: JSONColumnType<
         {
             [key: string]: any

+ 46 - 0
src/validations/auth.validation.ts

@@ -0,0 +1,46 @@
+import { z } from "zod"
+import { password } from "./custom.refine.validation"
+import { hashPassword } from "./custom.transform.validation"
+
+export const register = z.strictObject({
+    email: z.string().email(),
+    password: z.string().superRefine(password).transform(hashPassword),
+    name: z.string()
+})
+
+export type Register = z.infer<typeof register>
+
+export const login = z.strictObject({
+    email: z.string(),
+    password: z.string()
+})
+
+export const refreshTokens = z.strictObject({
+    refresh_token: z.string()
+})
+
+export const forgotPassword = z.strictObject({
+    email: z.string().email()
+})
+
+export const resetPassword = z.strictObject({
+    query: z.object({
+        token: z.string()
+    }),
+    body: z.object({
+        password: z.string().superRefine(password).transform(hashPassword)
+    })
+})
+
+export const verifyEmail = z.strictObject({
+    token: z.string()
+})
+
+export const changePassword = z.strictObject({
+    oldPassword: z.string().superRefine(password).transform(hashPassword),
+    newPassword: z.string().superRefine(password).transform(hashPassword)
+})
+
+export const oauthCallback = z.strictObject({
+    code: z.string()
+})

+ 21 - 0
src/validations/custom.refine.validation.ts

@@ -0,0 +1,21 @@
+import { z } from "zod"
+
+export const password = async (
+    value: string,
+    ctx: z.RefinementCtx
+): Promise<void> => {
+    if (value.length < 8) {
+        ctx.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: "password must be at least 8 characters"
+        })
+        return
+    }
+    if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) {
+        ctx.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: "password must contain at least 1 letter and 1 number"
+        })
+        return
+    }
+}

+ 6 - 0
src/validations/custom.transform.validation.ts

@@ -0,0 +1,6 @@
+import bcrypt from "bcryptjs"
+
+export const hashPassword = async (value: string): Promise<string> => {
+    const hashedPassword = await bcrypt.hash(value, 8)
+    return hashedPassword
+}

+ 3 - 0
src/validations/custom.type.validation.ts

@@ -0,0 +1,3 @@
+import { z } from "zod"
+
+export const roleZodType = z.union([z.literal("admin"), z.literal("user")])

+ 48 - 0
src/validations/user.validation.ts

@@ -0,0 +1,48 @@
+import { z } from "zod"
+import { password } from "./custom.refine.validation"
+import { hashPassword } from "./custom.transform.validation"
+import { roleZodType } from "./custom.type.validation"
+
+export const createUser = z.strictObject({
+    email: z.string().email(),
+    password: z.string().superRefine(password).transform(hashPassword),
+    name: z.string(),
+    is_email_verified: z
+        .any()
+        .optional()
+        .transform(() => false),
+    role: roleZodType
+})
+
+export type CreateUser = z.infer<typeof createUser>
+
+export const getUsers = z.object({
+    email: z.string().optional(),
+    sort_by: z.string().optional().default("id:asc"),
+    limit: z.coerce.number().optional().default(10),
+    page: z.coerce.number().optional().default(0)
+})
+
+export const getUser = z.object({ userId: z.coerce.number().positive().int() })
+
+export const updateUser = z.strictObject({
+    params: z.object({ userId: z.coerce.number().positive().int() }),
+    body: z
+        .object({
+            email: z.string().email().optional(),
+            name: z.string().optional(),
+            role: z.union([z.literal("admin"), z.literal("user")]).optional()
+        })
+        .refine(({ email, name, role }) => email || name || role, {
+            message: "At least one field is required"
+        })
+})
+
+export type UpdateUser =
+    | z.infer<typeof updateUser>["body"]
+    | { password: string }
+    | { is_email_verified: boolean }
+
+export const deleteUser = z.strictObject({
+    userId: z.coerce.number().positive().int()
+})

+ 4 - 2
wrangler.toml

@@ -1,8 +1,10 @@
 name = "my-app"
 compatibility_date = "2023-12-01"
 
-# [vars]
-# MY_VAR = "my-variable"
+[vars]
+JWT_SECRET="RfR*P!P^9@suU&"
+JWT_EXPIRATION=2592000
+REGISTRATION_ENABLED = false
 
 # [[kv_namespaces]]
 # binding = "MY_KV_NAMESPACE"