birchline/web · 架构笔记

认证是怎么在代码库里流转的

Birchline 采用基于 cookie 的会话——浏览器从不直接持有 bearer token。每个需要认证的请求都打到 /api/*,经过唯一的一个 verifyToken() 中间件,解出一条 Session 记录,下游处理器从 req.ctx 上读取。中间件是唯一与 session store 对话的地方,而 session store 又是唯一与 sessions 表对话的地方——也就是说,整个系统只有一处信任边界需要推理。

请求路径

浏览器 birchline.app /api/session 路由处理器 verifyToken() middleware/auth.ts SessionStore lib/sessionStore.ts Postgres sessions 表 cookie 查询

调用栈走读

1
src/app/providers/AuthProvider.tsx :22-48

挂载时,这个 React provider 发出一个 GET /api/session 请求,并带上 credentials: 'include',让 fw_sid cookie 一起附上。响应要么把 currentUser 注入到 context,要么留为 null——后者被路由识别为"展示登录界面"。

查看源码
// src/app/providers/AuthProvider.tsx
export function AuthProvider({ children }: Props) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetch('/api/session', { credentials: 'include' })
      .then(r => r.ok ? r.json() : null)
      .then(setUser);
  }, []);

  return <AuthCtx.Provider value={{ user }}>{children}</AuthCtx.Provider>;
}
2
src/server/routes/session.ts :9-27

这个路由本身很薄:它只是把中间件挂到 req.ctx.session 上的内容原样返回。如果中间件已经短路返回 401,这个处理器根本不会执行——所以这里没有重复的认证逻辑。

查看源码
// src/server/routes/session.ts
router.get('/session', verifyToken, (req, res) => {
  const { session } = req.ctx;
  res.json({
    id:    session.userId,
    email: session.email,
    role:  session.role,
    exp:   session.expiresAt,
  });
});
3
src/middleware/auth.ts :14-31

这里就是信任边界。verifyToken 读取已签名的 fw_sid cookie,请 SessionStore 解析它,要么把结果挂到 req.ctx.session 上,要么返回 401。应用里所有受保护的路由都挂在这个函数后面,所以改它的行为,就等于全局改变了认证。

查看源码
// src/middleware/auth.ts
export async function verifyToken(req, res, next) {
  const raw = req.signedCookies['fw_sid'];
  if (!raw) return res.status(401).end();

  const session = await SessionStore.get(raw);
  if (!session || session.expiresAt < Date.now()) {
    return res.status(401).end();
  }

  req.ctx = { session };
  next();
}
4
src/lib/sessionStore.ts :8-52

SessionStore 是一个小巧的"读穿透"缓存:先查进程内的 LRU,未命中再回落到 Postgres。写操作(createrevoke)一律直接落库,并使对应缓存项失效,避免其他 worker 端出过期会话。

查看源码
// src/lib/sessionStore.ts
const cache = new LRU<string, Session>({ max: 5000, ttl: 60_000 });

export const SessionStore = {
  async get(id: string) {
    const hit = cache.get(id);
    if (hit) return hit;
    const row = await db.one(SELECT_SESSION, [id]);
    if (row) cache.set(id, row);
    return row ?? null;
  },
  /* create、revoke、touch ... */
};
5
db/migrations/004_sessions.sql :1-18

sessions 表的主键是一个随机 32 字节的 id(即 cookie 值),并在 user_id 上建有覆盖索引,用于"全部设备登出"。过期时间在这里(expires_at)和中间件里都做了校验,作为纵深防御。

查看源码
-- db/migrations/004_sessions.sql
create table sessions (
  id          text primary key,
  user_id     uuid not null references users(id),
  created_at  timestamptz default now(),
  expires_at  timestamptz not null,
  ip          inet,
  user_agent  text
);
create index sessions_user_id_idx on sessions(user_id);