项目动机
- Next Auth 太复杂,自己的个人项目不想用到Oauth这样的高级特性。只想用email/username 和 password的组合。
- lucia-auth.com/ v3 简单方便实现传统的 email/username 和 password 组合。
- lucia-auth v3 预设 session 的 id 是 string 类型。与自己常用数据库自带的自增id差异比较大。改造起来比较麻烦。
- 在go 世界中用惯了 entgo.io 里的数据库生产,更改工具。希望别的项目也能按照这个工具的风格来定义表的结构 。
- lucia-auth 一个月前改版,V3 成为不推荐的库,成为学习类项目。本人个人项目中用的v3 将来不能使用,这样有动力去看一下 API 实现的源码。发现自己用自增id实现同样的API 难度不大。自己造半个轮子。
- svelte 5 自带的
sv
工具可以生成 lucia auth 最新的 API 脚手架代码。本人可以在此基础上造半个轮子。
项目使用的技术
- svelte 5
sv
(其实可以使用任何全栈框架,next/nuxt) - postgresql
- pg 库 (SQL 驱动)
- drizzle (ORM 库),用 Prisma 应该也是类似。过几天会改造另外一个用 prisma 当orm的项目。
- Typescript
- lucia auth 提供的基本authentication 库
项目代码
主要改造的代码
- schema 定义符合 entgo 的风格
src/lib/server/db/schema.ts
import { foreignKey, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: integer().primaryKey().generatedByDefaultAsIdentity({
name: 'users_id_seq',
startWith: 1,
increment: 1,
cache: 1
}),
username: text('username').notNull().unique(),
password: text('password').notNull()
});
export const sessions = pgTable(
'sessions',
{
id: integer().primaryKey().generatedByDefaultAsIdentity({
name: 'sessions_id_seq',
startWith: 1,
increment: 1,
cache: 1
}),
userSessions: integer('user_sessions').notNull(),
sessionId: text('session_id').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull()
},
(table) => {
return {
sessionsUsersSessions: foreignKey({
columns: [table.userSessions],
foreignColumns: [users.id],
name: 'sessions_users_sessions'
})
};
}
);
export type Session = typeof sessions.$inferSelect;
export type User = typeof users.$inferSelect;
其中的几个关键点:
- id 使用自增
- 库名使用带s的复数形式
- sessions库中原来id是由token生成的string 类型,现在把它增加为 sessionID 列。
- 改变外键的命名规范,符合 entgo 的风格。
- 根据 schema的风格和lucia的原理,改变api的内部实现
src/lib/server/auth.ts
- 让数据库自己生成自增id,并返回生成好的 session 记录。
export async function createSession(token: string, userId: number) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
return await db
.insert(table.sessions)
.values({ userSessions: userId, sessionId, expiresAt: new Date(Date.now() + DAY_IN_MS * 30) })
.returning();
}
- 比较session时做相应的改变,table.sessions.sessionID 与 传入的 token 生成的id做比较。
export async function validateSessionToken(token: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const [result] = await db
.select({
// Adjust user table here to tweak returned data
user: { id: table.users.id, username: table.users.username },
session: table.sessions
})
.from(table.sessions)
.innerJoin(table.users, eq(table.sessions.userSessions, table.users.id))
.where(eq(table.sessions.sessionId, sessionId));
if (!result) {
return { session: null, user: null };
}
const { session, user } = result;
const sessionExpired = Date.now() >= session.expiresAt.getTime();
if (sessionExpired) {
await db.delete(table.sessions).where(eq(table.sessions.sessionId, session.sessionId));
return { session: null, user: null };
}
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
if (renewSession) {
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
await db
.update(table.sessions)
.set({ expiresAt: session.expiresAt })
.where(eq(table.sessions.sessionId, session.sessionId));
}
return { session, user };
}
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
export async function invalidateSession(sessionId: string) {
await db.delete(table.sessions).where(eq(table.sessions.sessionId, sessionId));
}
结论
正如 lucia auth 作者所述,写一个lib需要考虑的东西太多,众口难调。当底层函数足够好用,设计思路清晰的情况下,给造轮子的人有了更灵活和坚实的基础。