5 min read
迁移 Lucia 3

为什么需要迁移

Lucia 的作者计划明年 2025 年 1 月 1 日 停止 Lucia 3 的维护。

他觉得 Lucia 应该成为 JavaScript 实现身份验证的开源资源。

因为它底层的实现非常简单,就基于两个库:@oslojs/encoding@oslojs/crypto

所以作者希望我们将学习如何从头开始实现权限控制,而不是安装该库。

具体可以参考:

https://github.com/lucia-auth/lucia/discussions/1707

即使这样我为什么还要继续使用 Lucia?

之前尝试过使用 next-auth,当然文档还是个最大问题,在 coding 的过程中我还需要去查看它的源码才能满足自己的需求,以及库作者对于 password 的看法,最终还是决定使用 Lucia。

以下是我使用 Lucia 的理由:

  • 因为 Lucia 仍然是一个很好的从头开始实现身份验证的原始库。
  • 独立于付费的第三方
  • 可以学习身份验证,掌握控制权

安装依赖

pnpm add @oslojs/encoding @oslojs/crypto

如果之前有安装过 lucia 和相关的 adapter,请先删除,我删除的是:

pnpm remove lucia @lucia-auth/adapter-drizzle

lib/lucia/crypto.ts

首先创建 lib/lucia/crypto.ts 文件,该文件主要是生成 session token 和 hash token 的逻辑。

import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';

export function generateIdFromEntropySize(size: number): string {
  const buffer = crypto.getRandomValues(new Uint8Array(size));
  return encodeBase32LowerCaseNoPadding(buffer);
}

export function hashToken(token: string) {
  return encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
}

lib/lucia/index.ts

创建 lib/lucia/index.ts 文件,该文件主要是创建 session 和验证 session token 的逻辑。

我这边用的是 drizzle-orm,所以在 sessionuser 需要引入 drizzle-orm 的类型。

如果你用的是 prisma 或者 typeorm 等其他数据库,请自行替换。

基础类型

这部分主要是定义 session 验证的结果类型。

定义了 session 的过期时间 30 天,你可以根据实际情况调整。

并且定义了 session 如果到了 15 天,则需要刷新 session。

import { Session } from '@/zod/session';
import { SelectUser } from '@/zod/user';

export type SessionValidationResult =
  | { session: Session; user: SelectUser; fresh: boolean }
  | { session: null; user: null; fresh: boolean };

const SESSION_REFRESH_INTERVAL_MS = 1000 * 60 * 60 * 24 * 15; // 15 days
const SESSION_MAX_DURATION_MS = SESSION_REFRESH_INTERVAL_MS * 2; // 30 days

生成 session token

import { generateIdFromEntropySize, hashToken } from './crypto';

export function generateSessionToken(): string {
  const token = generateIdFromEntropySize(20);
  return hashToken(token);
}

创建 session

我这边的 dbdrizzle-orm,所以创建 session 的逻辑如下:

import { db } from '@/db';
import { sessionTable } from '@/db/schema';

export async function createSession(sessionToken: string, userId: string): Promise<Session> {
  const session = {
    id: sessionToken,
    userId,
    expiresAt: new Date(Date.now() + SESSION_MAX_DURATION_MS),
  };
  const [dbSession] = await db.insert(sessionTable).values(session).returning();
  return dbSession;
}

验证 session token

这部分主要是验证 session token 是否有效,如果有效则返回 session 和 user。

如果 session 过期,则删除 session。

如果 session 快要过期并且达到了之前设定的刷新时间,则刷新 session。

import { db } from '@/db';
import { sessionTable } from '@/db/schema';
import { eq } from 'drizzle-orm';

export async function validateSessionToken(sessionToken: string): Promise<SessionValidationResult> {
  let fresh = false;
  const result = await db.query.sessionTable.findFirst({
    where: eq(sessionTable.id, sessionToken),
    with: {
      user: true,
    },
  });
  if (!result) {
    return { session: null, user: null, fresh };
  }

  const { user, ...session } = result;

  // 如果 session 过期,删除 session
  if (Date.now() >= session.expiresAt.getTime()) {
    await db.delete(sessionTable).where(eq(sessionTable.id, sessionToken));

    return { session: null, user: null, fresh };
  }

  // 如果 session 快要过期,刷新 session
  if (Date.now() >= session.expiresAt.getTime() - SESSION_REFRESH_INTERVAL_MS) {
    session.expiresAt = new Date(Date.now() + SESSION_MAX_DURATION_MS);
    // 设置为 fresh 表示 session 被刷新了
    fresh = true;
    await db.update(sessionTable).set({ expiresAt: session.expiresAt }).where(eq(sessionTable.id, sessionToken));
  }

  return { session, user, fresh };
}

删除 session

import { db } from '@/db';
import { sessionTable } from '@/db/schema';
import { eq } from 'drizzle-orm';

export async function invalidateSession(sessionId: string): Promise<void> {
  await db.delete(sessionTable).where(eq(sessionTable.id, sessionId));
}

lib/lucia/cookie.ts

这部分主要是生成 cookie 的逻辑。

因为我们需要将 sessiontoken 存储在 cookie 中。

如果你使用的是 Nextjs 生成后的 cookie 你就可以使用 next/headers 去设置 cookie

基础类型

export interface CookieAttributes {
  secure?: boolean;
  path?: string;
  domain?: string;
  sameSite?: 'lax' | 'strict' | 'none';
  httpOnly?: boolean;
  maxAge?: number;
  expires?: Date;
}

export interface SessionCookie {
  name: string;
  value: string;
  attributes: CookieAttributes;
}

export const SESSION_COOKIE_NAME = 'session';

const cookieOptions: CookieAttributes = {
  httpOnly: true,
  sameSite: 'lax' as const,
  secure: process.env.NODE_ENV === 'production',
  path: '/',
};
export function createCookie(cookieValue: string, expiresAt: Date): SessionCookie {
  return {
    name: SESSION_COOKIE_NAME,
    value: cookieValue,
    attributes: { ...cookieOptions, expires: expiresAt },
  };
}

export function createBlankCookie(): SessionCookie {
  return {
    name: SESSION_COOKIE_NAME,
    value: '',
    attributes: { ...cookieOptions, maxAge: 0 },
  };
}

最后

是不是很简单?其实对于 Lucia 的迁移,作者已经给出了一个非常详细的指南。

https://lucia-auth.com/sessions/migrate-lucia-v3

如果你想要对接第三方登录的话可以查看作者的另外一个库:Arctic https://arcticjs.dev/

lucia/index.ts 完整代码

import { db } from '@/db';
import { sessionTable } from '@/db/schema';
import { Session } from '@/zod/session';
import { SelectUser } from '@/zod/user';
import { eq } from 'drizzle-orm';
import { generateIdFromEntropySize, hashToken } from './crypto';

export type SessionValidationResult =
  | { session: Session; user: SelectUser; fresh: boolean }
  | { session: null; user: null; fresh: boolean };

const SESSION_REFRESH_INTERVAL_MS = 1000 * 60 * 60 * 24 * 15; // 15 days
const SESSION_MAX_DURATION_MS = SESSION_REFRESH_INTERVAL_MS * 2; // 30 days

export function generateSessionToken(): string {
  const token = generateIdFromEntropySize(20);
  return hashToken(token);
}

export async function createSession(sessionToken: string, userId: string): Promise<Session> {
  const session = {
    id: sessionToken,
    userId,
    expiresAt: new Date(Date.now() + SESSION_MAX_DURATION_MS),
  };
  const [dbSession] = await db.insert(sessionTable).values(session).returning();
  return dbSession;
}

export async function validateSessionToken(sessionToken: string): Promise<SessionValidationResult> {
  let fresh = false;
  const result = await db.query.sessionTable.findFirst({
    where: eq(sessionTable.id, sessionToken),
    with: {
      user: true,
    },
  });
  if (!result) {
    return { session: null, user: null, fresh };
  }

  const { user, ...session } = result;

  // if the session is expired, delete it
  if (Date.now() >= session.expiresAt.getTime()) {
    await db.delete(sessionTable).where(eq(sessionTable.id, sessionToken));

    return { session: null, user: null, fresh };
  }

  // if the session is about to expire, refresh it
  if (Date.now() >= session.expiresAt.getTime() - SESSION_REFRESH_INTERVAL_MS) {
    session.expiresAt = new Date(Date.now() + SESSION_MAX_DURATION_MS);
    fresh = true;
    await db.update(sessionTable).set({ expiresAt: session.expiresAt }).where(eq(sessionTable.id, sessionToken));
  }
  return { session, user, fresh };
}

export async function invalidateSession(sessionId: string): Promise<void> {
  await db.delete(sessionTable).where(eq(sessionTable.id, sessionId));
}

参考

扫码_搜索联合传播样式-标准色版