feat: Implement username/password authentication and registration features

This commit is contained in:
beilunyang
2025-01-15 16:00:06 +08:00
parent 969d0ce334
commit 126a4cb948
24 changed files with 2643 additions and 67 deletions
+110 -30
View File
@@ -2,10 +2,14 @@ import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import { DrizzleAdapter } from "@auth/drizzle-adapter"
import { createDb, Db } from "./db"
import { accounts, sessions, users, roles, userRoles } from "./schema"
import { accounts, users, roles, userRoles } from "./schema"
import { eq } from "drizzle-orm"
import { getRequestContext } from "@cloudflare/next-on-pages"
import { Permission, hasPermission, ROLES, Role } from "./permissions"
import CredentialsProvider from "next-auth/providers/credentials"
import { hashPassword, comparePassword } from "@/lib/utils"
import { authSchema } from "@/lib/validation"
import { generateAvatarUrl } from "./avatar"
const ROLE_DESCRIPTIONS: Record<Role, string> = {
[ROLES.EMPEROR]: "皇帝(网站所有者)",
@@ -71,13 +75,53 @@ export const {
adapter: DrizzleAdapter(createDb(), {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
}),
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
})
}),
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "用户名", type: "text", placeholder: "请输入用户名" },
password: { label: "密码", type: "password", placeholder: "请输入密码" },
},
async authorize(credentials) {
if (!credentials) {
throw new Error("请输入用户名和密码")
}
const { username, password } = credentials
try {
authSchema.parse({ username, password })
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
throw new Error("输入格式不正确")
}
const db = createDb()
const user = await db.query.users.findFirst({
where: eq(users.username, username as string),
})
if (!user) {
throw new Error("用户名或密码错误")
}
const isValid = await comparePassword(password as string, user.password as string)
if (!isValid) {
throw new Error("用户名或密码错误")
}
return {
...user,
password: undefined,
}
},
}),
],
events: {
async signIn({ user }) {
@@ -99,37 +143,73 @@ export const {
}
},
},
pages: {
signIn: "/",
error: "/",
},
callbacks: {
async session({ session, user }) {
if (!session?.user) return session
const db = createDb()
let userRoleRecords = await db.query.userRoles.findMany({
where: eq(userRoles.userId, user.id),
with: { role: true },
})
if (!userRoleRecords.length) {
const defaultRole = await getDefaultRole()
const role = await findOrCreateRole(db, defaultRole)
await assignRoleToUser(db, user.id, role.id)
userRoleRecords = [{
userId: user.id,
roleId: role.id,
createdAt: new Date(),
role: role
}]
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.name = user.name || user.username
token.username = user.username
token.image = user.image || generateAvatarUrl(token.name as string)
}
return token
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string
session.user.name = token.name as string
session.user.username = token.username as string
session.user.image = token.image as string
session.user.roles = userRoleRecords.map(ur => ({
name: ur.role.name,
}))
const db = createDb()
let userRoleRecords = await db.query.userRoles.findMany({
where: eq(userRoles.userId, session.user.id),
with: { role: true },
})
if (!userRoleRecords.length) {
const defaultRole = await getDefaultRole()
const role = await findOrCreateRole(db, defaultRole)
await assignRoleToUser(db, session.user.id, role.id)
userRoleRecords = [{
userId: session.user.id,
roleId: role.id,
createdAt: new Date(),
role: role
}]
}
session.user.roles = userRoleRecords.map(ur => ({
name: ur.role.name,
}))
}
return session
},
}
},
session: {
strategy: "jwt",
},
}))
export async function register(username: string, password: string) {
const db = createDb()
const existing = await db.query.users.findFirst({
where: eq(users.username, username)
})
if (existing) {
throw new Error("用户名已存在")
}
const hashedPassword = await hashPassword(password)
const [user] = await db.insert(users)
.values({
username,
password: hashedPassword,
})
.returning()
return user
}
+48
View File
@@ -0,0 +1,48 @@
const COLORS = [
'#2196F3', // 蓝色
'#009688', // 青色
'#9C27B0', // 紫色
'#F44336', // 红色
'#673AB7', // 深紫色
'#3F51B5', // 靛蓝
'#4CAF50', // 绿色
'#FF5722', // 深橙
'#795548', // 棕色
'#607D8B', // 蓝灰
];
export function generateAvatarUrl(name: string): string {
const initial = name[0].toUpperCase();
const colorIndex = Array.from(name).reduce(
(acc, char) => acc + char.charCodeAt(0), 0
) % COLORS.length;
const backgroundColor = COLORS[colorIndex];
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" fill="${backgroundColor}"/>
<text
x="50%"
y="50%"
fill="white"
font-family="system-ui, -apple-system, sans-serif"
font-size="45"
font-weight="500"
text-anchor="middle"
alignment-baseline="central"
dominant-baseline="central"
style="text-transform: uppercase"
>
${initial}
</text>
</svg>
`.trim();
const encoder = new TextEncoder();
const bytes = encoder.encode(svg);
const base64 = Buffer.from(bytes).toString('base64');
return `data:image/svg+xml;base64,${base64}`;
}
+5 -12
View File
@@ -11,8 +11,9 @@ export const users = sqliteTable("user", {
email: text("email").unique(),
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
image: text("image"),
username: text("username").unique(),
password: text("password"),
})
export const accounts = sqliteTable(
"account",
{
@@ -36,15 +37,7 @@ export const accounts = sqliteTable(
}),
})
)
export const sessions = sqliteTable("session", {
sessionToken: text("sessionToken").primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
})
export const emails = sqliteTable("email", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
address: text("address").notNull().unique(),
@@ -54,7 +47,7 @@ export const emails = sqliteTable("email", {
.$defaultFn(() => new Date()),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
})
export const messages = sqliteTable("message", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
emailId: text("emailId")
@@ -68,7 +61,7 @@ export const messages = sqliteTable("message", {
.notNull()
.$defaultFn(() => new Date()),
})
export const webhooks = sqliteTable('webhook', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id')
+14 -1
View File
@@ -3,4 +3,17 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
}
export async function hashPassword(password: string): Promise<string> {
const encoder = new TextEncoder()
const salt = process.env.AUTH_SECRET || ''
const data = encoder.encode(password + salt)
const hash = await crypto.subtle.digest('SHA-256', data)
return btoa(String.fromCharCode(...new Uint8Array(hash)))
}
export async function comparePassword(password: string, hashedPassword: string): Promise<boolean> {
const hash = await hashPassword(password)
return hash === hashedPassword
}
+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod"
export const authSchema = z.object({
username: z.string().min(1, "用户名不能为空"),
password: z.string().min(8, "密码长度必须大于等于8位"),
})
export type AuthSchema = z.infer<typeof authSchema>