mirror of
https://github.com/beilunyang/moemail.git
synced 2026-09-06 15:56:36 +08:00
feat: Implement username/password authentication and registration features
This commit is contained in:
+110
-30
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user