ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

freehire 用户认证体系落地指南:从匿名 API 到 JWT + httpOnly Cookie 会话

freehire 用户认证体系落地指南:从匿名 API 到 JWT + httpOnly Cookie 会话
  • 后端
  • 前端
  • 搜索引擎
  • 网页爬虫
  • 数据工程
  • AI 应用
  • 可观测性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载

freehire 最初的后端只对外暴露只读的jobs与companies接口,整个系统没有“用户”这一概念,因此收藏职位、投递记录、管理端写操作等功能都无从附着。本指南以归档变更add-user-auth的提案为骨架,完整拆解这次认证基础设施的落地路径:users表与邮箱唯一键、bcrypt 口令存储、HS256 JWT、/api/v1/auth四个端点、可复用的RequireAuth中间件,以及 Svelte SPA 在布局层的登录态集成。读完你可以按同样的顺序在自己的 Go + Fiber + Postgres 项目中复刻这套方案,并理解每一个安全取舍背后的原因。

原文将这次改动定义为“为后续功能打地基”:先有身份与认证请求的能力,才谈得上归属与保护。下文会完整继承提案中的 Why / What / Impact 结构,并对照当前仓库中已实现的代码(认证包已迁移到internal/identity/auth)补充参数取值、调用链与测试证据。

一、为什么必须先有用户身份

提案开篇给出了清晰的动机链条:hire后端与其 Svelte SPA 完全匿名,没有用户概念,于是“任何东西都无法被个性化、归属或保护”。在保存职位(saved jobs)、投递记录(applications)、仅管理员可执行的写操作(admin-only mutations)这类特性出现之前,项目必须先具备两件东西:

  • 一个可信的用户身份(user identity);
  • 一种对请求进行认证(authenticate)的方式。

这是典型的“基础设施先行”排序:认证属于横切关注点,一旦业务功能先落地再把身份补进来,就要回头改造每一张表、每一个处理器。因此这次变更刻意做成只增不改——新增表、新增路由、新增包,现有读接口一行不动。

二、变更范围总览

提案的 “What Changes” 一节列出了六个方面的改动,可以看作本文的目录骨架:

维度内容
数据新增users表(email + bcrypt 口令哈希),以email作为唯一登录键
接口/api/v1/auth下新增POST /register、POST /login、GET /me(后续实现补上POST /logout)
认证机制无状态 JWT(HS256),签发于注册/登录,由 Fiber 中间件校验后把用户身份注入请求上下文
边界现有jobs、companies读接口保持公开且不变,仅新增可复用的“要求认证”守卫
配置JWT_SECRET、Token TTL
前端在布局层集成认证:登录态 store、登录/注册表单、顶栏展示用户与登出

后台实现与提案有一处重要的机制差异,值得先说清楚:提案正文写的是“Authorization: Bearer头 + localStorage 持久化 token”,而同一变更下的design.md与specs/user-auth/spec.md最终收敛为httpOnly Cookie 承载 JWT、前端不持有任何 token。当前仓库实现采纳的是后者(web/src/lib/auth.svelte.ts 明确注释“session 活在浏览器管理的 httpOnly cookie 里,这里没有 token”)。下文以最终规范与代码为准,并在前端章节解释这一演进的动机。

三、数据模型:users表

3.1 提案中的建表语句

提案的设计文档给出了migrations/0005_users.sql的目标形态:

id bigint generated always as identity primary key email text not null password_hash text -- NULLABLE: passwordless users have none created_at timestamptz not null default now() unique (lower(email)) -- case-insensitive uniqueness

三个设计点值得逐条展开:

  1. password_hash可空。这不是疏忽。变更明确宣布下一轮迭代要做 Google OAuth 与 magic-link 登录,而这类用户根本没有口令。若此刻写成NOT NULL,未来要放宽就必须再写一条迁移;而项目当时还没有迁移执行器(migration runner),修改0005不会对已存在的数据库卷重新生效,线上库做NOT NULL → NULL变更相当痛苦。现在留空是零成本的。
  2. UNIQUE (lower(email))而非UNIQUE (email)。用函数索引把大小写归一化交给数据库兜底,应用层再把邮箱统一转小写存储,两者配合实现“一个邮箱一个账号,任意大小写都算重复”。
  3. 邮箱是唯一身份键。这是“一个账号、多种登录方式”的地基:今天是密码,将来 Google 与 magic-link 都归并或关联到同一个以邮箱为键的用户上。JWT 里只携带 user id(sub),因此天然与登录提供方无关。

3.2 当前仓库中的实际形态

仓库后来把历史迁移折叠成单一基线 migrations/0001_init.sql,users表就落在其中:

CREATE TABLE public.users ( id bigint NOT NULL, email text NOT NULL, password_hash text, created_at timestamp with time zone DEFAULT now() NOT NULL, role text DEFAULT 'user'::text NOT NULL, ... CONSTRAINT users_role_check CHECK ((role = ANY (ARRAY['user'::text, 'moderator'::text, 'admin'::text]))) );

可以看到提案的核心三列原样保留,password_hash也确实保持可空。大小写不敏感的唯一性由独立索引承载:

CREATE UNIQUE INDEX users_email_lower_idx ON public.users USING btree (lower(email));

后续演进在这个骨架上做了纯增量扩展:role列(来自users_role_check)、email_verified、token_version(见 migrations/0041_account_security.sql)等,都符合“不重做users”的原始承诺。

3.3 sqlc 查询契约

提案要求新增internal/db/queries/users.sql,包含CreateUser、GetUserByEmail(登录用,含password_hash)、GetUserByID(/me用,不含password_hash)三个查询。当前实现位于 internal/platform/db/queries/users.sql,语义与提案一致:

  • CreateUser:插入并RETURNING id, email, role, beta_tester, email_verified, created_at, timezone, language,返回体天然不含口令哈希;
  • GetUserByEmail:WHERE lower(email) = lower($1),是唯一会取出password_hash的读路径;
  • GetUserByID:把哈希降级为布尔量(password_hash IS NOT NULL)::boolean AS has_password,使“账号是否有口令”成为可下发的信息,而哈希本身永远不出库。

这条“哈希只在一个查询里出现”的规则,是后面userResponse能安全序列化的前提。

四、认证原语:internal/auth包

提案要求把安全原语从 HTTP 处理器中剥离出来,放进独立的internal/auth/包,让密码学与令牌逻辑可以脱离数据库单独测试。当前仓库把它安置在 internal/identity/auth 下,职责划分与提案逐条对应。

4.1 口令哈希:bcrypt

internal/identity/auth/password.go 只做两件事:

func HashPassword(plain string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) ... } func CheckPassword(hash, plain string) error { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) }

选用 bcrypt 的理由在设计文档中写得很直接:盐(salt)与代价因子(cost)都嵌在哈希字符串里,所以users表只需要一列password_hash,不需要额外的 salt 列。argon2id 更强,但要手工调参 salt/time/memory;对 MVP 而言 bcrypt 是更简单、经过验证的默认值。

这里有一个容易忽略的实现细节:bcrypt 的输入上限是 72 字节,超出部分会被静默截断。仓库在账号服务层显式拒绝超长口令,而不是让用户以为后半段生效了——见 internal/identity/accounts/accounts.go 中maxPasswordLen = 72的注释与错误映射ErrPasswordTooLong(对应 HTTP 400)。

4.2 令牌签发:Issuer

internal/identity/auth/token.go 用一个Issuer统一封装密钥与 TTL:

type Issuer struct { secret []byte ttl time.Duration } func NewIssuer(secret string, ttl time.Duration) *Issuer func (i *Issuer) TTL() time.Duration func (i *Issuer) Issue(userID int64, tokenVersion int32) (string, error) func (i *Issuer) Parse(token string) (int64, int32, error)

Issue生成的载荷是标准注册声明加一个私有声明:

声明含义
sub用户 id 的十进制字符串
exp当前时间 + Issuer 的 TTL
iat签发时间
jti16 字节随机数的 base64url 编码,标识“这一次会话”
tv账号的token_version,指针类型,缺失与 0 可区分

Parse的校验顺序与安全意图值得完整引用其行为:

  • 通过jwt.ParseWithClaims的回调强制校验签名算法属于 HMAC,任何非 HMAC 的alg都会报unexpected signing method,从而挡住算法混淆(algorithm confusion)攻击;
  • 校验签名与过期时间由库完成;
  • sub必须能解析为int64,否则返回invalid token subject;
  • tv缺失时返回专用错误ErrNoTokenVersion,与“签名错”“已过期”区分开——调用方因此能分辨“被版本吊销”和“被伪造”。

会话级指纹SessionFingerprint(token)先用 SHA-256 对完整 token 做单向摘要,并要求 token 必须带jti;没有 JTI 的历史会话返回ErrNoSessionID。这套机制服务于“最近一次认证”(recent auth)这类需要把证明绑定到某一次具体会话的场景。

4.3 Cookie 传输层

internal/identity/auth/cookie.go 把 Cookie 属性收敛到唯一一处:

const CookieName = "hire_token" func SetTokenCookie(c *fiber.Ctx, token string, ttl time.Duration, secure bool, domain string) func ClearTokenCookie(c *fiber.Ctx, secure bool, domain string) func writeTokenCookie(c *fiber.Ctx, value string, expires time.Time, secure bool, domain string)

writeTokenCookie中固定写死或按配置注入的属性为:Path=/、HTTPOnly: true、SameSite=Lax、Secure来自配置、Domain为空时退化为 host-only cookie、Expires与 token TTL 对齐。注释里说明了“单一属性来源”的用意:浏览器只会覆盖属性完全匹配的 Cookie,如果把设置与清除写在两处,两边一旦漂移,登出就会静默失败。

ClearTokenCookie还处理了一个真实的迁移陷阱:当配置了COOKIE_DOMAIN(例如.freehire.me)时,通过c.Cookie()清除只能命中带 Domain 的 Cookie,无法删除早期遗留的 host-only Cookie;而 fasthttp 会按名称去重Set-Cookie,第二次调用会覆盖第一次。因此实现改为直接用c.Response().Header.Add("Set-Cookie", raw)追加一条 host-only 的过期指令,确保两种作用域都被清掉。

域名的计算在 internal/identity/auth/domain.go:

func CookieDomainForHost(host string, domains []string) string { if i := strings.IndexByte(host, ':'); i >= 0 { host = host[:i] } for _, d := range domains { if host == d || strings.HasSuffix(host, "."+d) { return "." + d } } return "" }

即:请求主机命中配置的可注册域(含子域)时,下发.freehire.me形式的父域 Cookie,让freehire.me与apply.freehire.me共享同一会话(统一 SSO);否则退化为 host-only,开发环境即为此路径。

4.4 Fiber 中间件族

internal/identity/auth/middleware.go 定义了一组可组合的守卫,提案要求的RequireAuth是其中最基础的一个:

中间件行为失败语义
RequireAuth只读 Cookie,校验后把用户 id 写入c.Locals缺失/过期/非法/被吊销 → 401;数据库故障 → 503
OptionalCookieAuth有有效 Cookie 就附加身份,否则匿名放行不拒绝(用于 OAuth 回调等顶层导航)
OptionalAuthCookie 或 API key 有效则附加身份,否则匿名公开读接口上叠加“我的投票”等个性化信息
RequireAuthOrKey/RequireAuthOrScopedKeyCookie 优先,其次Authorization: Bearer凭据(会话 JWT 或 API key)两者都不成立 → 401;key 作用域不足 → 403
RequireRole认证之后按数据库中的role授权无身份 → 401;角色不符 → 403

三个实现细节直接对应提案的安全目标:

  1. 身份注入用LocalsUserID = "auth.userID"。之所以导出这个键,是因为 WebSocket 处理器拿到的是Conn而不是Ctx,只能按字符串键读取继承的 local。
  2. 会话校验不止验签。resolveSession在验签之后还会用Issuer.Parse返回的tv与数据库中的当前值比对:
id, version, err := iss.Parse(token) if err != nil { return 0, sessionInvalid, nil } current, err := versions.GetUserTokenVersion(c.Context(), id) ... if current != version { return 0, sessionRevoked, nil }

versions是一个只含GetUserTokenVersion的小接口,由*db.Queries直接满足,因此 auth 包不引入数据库依赖。这一层“查一次主键”正是让无状态 JWT 具备吊销能力的代价——提案在 Risks 中承认“无令牌吊销”是取舍,后续用token_version补齐了这块短板。 3.区分“伪造”与“基础设施故障”。数据库返回pgx.ErrNoRows(用户已不存在)判为sessionRevoked;其它错误判为sessionInfraError并回 503,绝不把真实的数据库故障伪装成 401 让客户端反复重试登录。

五、HTTP 接口层

5.1 端点清单

提案列出三个端点,最终实现补齐了登出(登出在spec.md里被明确要求)。当前路由注册在 internal/api/handler/auth.go:

方法路径认证成功状态说明
POST/api/v1/auth/register公开201创建账号、下发会话 Cookie,返回用户
POST/api/v1/auth/login公开200校验凭据、下发会话 Cookie,返回用户
POST/api/v1/auth/logout公开、幂等204清除会话 Cookie
GET/api/v1/auth/me会话 Cookie 或 API key200返回当前用户

整个/auth分组挂了一条noCache中间件,统一写入Cache-Control: no-store与Pragma: no-cache,避免任何代理或浏览器缓存凭据类响应。

5.2 请求与响应示例

注册请求体(credentials结构体):

{ "email": "alice@example.com", "password": "correct horse battery staple", "timezone": "Europe/Berlin" }

timezone是可选字段,由浏览器Intl.DateTimeFormat().resolvedOptions().timeZone自动带入,仅在注册时使用;登录会忽略它,避免每次登录静默覆盖用户在资料页设置的时区。

成功响应统一包在{"data": ...}信封中(这是项目既有约定):

{ "data": { "id": 42, "email": "alice@example.com", "role": "user", "beta_tester": false, "email_verified": false, "has_password": true, "created_at": "2026-06-10T12:00:00Z", "timezone": "Europe/Berlin", "language": "en", "onboarding_completed_at": null, "tier": "free" } }

响应类型userResponse在设计上就不含password_hash,注释写着“故意省略,使哈希永不进入响应”,只保留has_password这个布尔量,让前端能为口令账号提供改密入口,也能向 OAuth-only 账号解释原因。

5.3 状态码与错误映射

accountsError把账号服务的哨兵错误集中映射为 HTTP 状态,这是提案要求的“处理器保持轻薄”的落地方式:

错误状态码响应消息
ErrInvalidEmail400invalid email
ErrPasswordTooShort400password must be at least 8 characters
ErrPasswordTooLong400password must be at most 72 characters
ErrEmailTaken409email already registered
ErrInvalidCredentials401invalid credentials
ErrUserNotFound401unauthorized
基础设施错误503service temporarily unavailable

其中 401 的“通用性”是刻意设计:未知邮箱、错误口令、以及没有口令的账号,三者返回完全相同的消息,绝不透露是哪一个因素失败。仓库进一步用常量时间兜住了时序侧信道——internal/identity/accounts/accounts.go 中定义了一个代价与生产哈希一致的假哈希:

const dummyPasswordHash = "$2a$10$uAK6XQv2KNKj0KXGXHcgAe3fn70C1tZiCUeG4ZCkdGGJ60p2.py7S"

当邮箱不存在、或账号没有口令时,登录流程仍会对着它跑一次 bcrypt 校验,使响应耗时与真实账号一致,从而避免通过时间差枚举“哪些邮箱注册过口令账号”。

5.4 处理器骨架

四个处理器都保持“解析 → 调用服务 → 设 Cookie → 组装响应”的线性结构:

func (h *authHandlers) Register(c *fiber.Ctx) error { var in credentials if err := c.BodyParser(&in); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid request body") } user, err := h.accounts.Register(c.Context(), in.Email, in.Password, in.Timezone) if err != nil { return accountsError(err) } if err := h.setSession(c, user.ID); err != nil { return err } return c.Status(fiber.StatusCreated).JSON(fiber.Map{"data": h.toUserResponseWithTier(c.Context(), user)}) }

setSession的职责是“读当前版本 → 签发 → 写 Cookie”:

func (h *authHandlers) setSession(c *fiber.Ctx, userID int64) error { version, err := h.queries.GetUserTokenVersion(c.Context(), userID) if err != nil { return fiber.NewError(fiber.StatusServiceUnavailable, "service temporarily unavailable") } return h.setSessionAt(c, userID, version) }

签发失败时不使用fiber.NewError(500, …),而是直接return fmt.Errorf(...):因为*fiber.Error不携带 cause,会被错误通道当成“无原因的 500”,丢失诊断信息。这是与项目的统一错误渲染器 internal/api/handler/errors.go 配合的细节。

登出则是清 Cookie 的幂等操作:

func (h *authHandlers) Logout(c *fiber.Ctx) error { domain := auth.CookieDomainForHost(c.Hostname(), h.cookieDomains) auth.ClearTokenCookie(c, h.cookieSecure, domain) ... return c.SendStatus(fiber.StatusNoContent) }

注意这里只清 Cookie,JWT 本身在exp之前依然有效——提案在 Risks 中就把这一点写成了已知取舍,并给出了后续用token_version补齐的路径(即当前的“全部登出”实现:递增版本号让所有旧 token 失效)。

5.5 凭据接口限流

提案的风险章节指出“登录没有速率限制”是暴力破解面。当前实现在路由注册处补上了基于 IP 的限流:

loginLimiter := ratelimit.Middleware(h.throttler, ratelimit.KeyByIP("login"), 10, time.Minute) registerLimiter := ratelimit.Middleware(h.throttler, ratelimit.KeyByIP("register"), 5, time.Minute)

即登录每 IP 每分钟 10 次、注册每 IP 每分钟 5 次,注册更严是因为它同时是邮件发送入口。

六、配置与启动

6.1 环境变量

提案要求新增JWT_SECRET、JWT_TTL,以及在设计文档中补充的COOKIE_SECURE。当前定义在 internal/platform/config/config.go:

变量默认值约束与影响
JWT_SECRET无默认启动时校验长度至少 32 字节,为空或过短直接失败
JWT_TTL720h(30 天)用envDuration解析;无法解析时回退到默认值
COOKIE_SECUREfalseENV=production或前端源为https://时必须为true
COOKIE_DOMAIN空逗号分隔的可注册域列表,前导点可选;空表示 host-only

配置校验Validate()的核心几条:

if s.Env == "production" && !s.CookieSecure { return errors.New("COOKIE_SECURE must be true in production") } if strings.HasPrefix(s.FrontendOrigin, "https://") && !s.CookieSecure { return errors.New("COOKIE_SECURE must be true when using HTTPS origin") } if len(s.JWTSecret) < 32 { return errors.New("JWT_SECRET is required and must be at least 32 bytes") }

“失败快”是设计文档反复强调的原则:JWT_SECRET没有安全默认值,服务器绝不能带着一个可猜测的签名密钥启动。

需要注意的是,提案正文写的 TTL 默认值是 24h,而实现把默认值定为 30 天(envDuration("JWT_TTL", 30*24*time.Hour)),并由 internal/platform/config/config_test.go 中的TestLoad_JWTTTLDefaultsWhenUnset锁定。若你的场景对令牌寿命更敏感,显式设置JWT_TTL=24h即可——这也解释了为什么登出之外的吊销能力(token_version)在实现中变得重要。

6.2 迁移与代码生成

提案的迁移计划给出了可执行的落地顺序,与仓库约定一致:

  1. 新增建表迁移;由于当时没有迁移执行器,只能通过重建开发库卷来应用(docker compose down -v && make up)。
  2. 加依赖、写users.sql查询、执行make sqlc,并提交生成的代码。
  3. 先实现internal/auth,再写处理器,最后在handler.Register中接线。
  4. 运行服务前设置JWT_SECRET;HTTPS 部署同时设置COOKIE_SECURE=true。开发环境下 Vite 代理把/api转发到后端,保证同源。

仓库现状是迁移已被折叠为单一基线(migrations/0001_init.sql),新环境由 Postgresinitdb一次性应用;sqlc的生成配置见 sqlc.yaml。

6.3 依赖

提案列出两个新增依赖:

  • golang.org/x/crypto/bcrypt—— 口令哈希;
  • github.com/golang-jwt/jwt/v5—— JWT 签发与校验,选它是因为它是 Go 生态事实标准,且 v5 提供了更安全的解析 API(强制校验签名算法即得益于此)。

两者均可从 go.mod 中确认。

七、前端集成:布局层的一次会话解析

提案要求把认证集成到 Svelte SPA 的布局层:客户端认证 store、登录/注册表单、顶栏展示用户与登出。这一节是变更中演进最明显的地方,值得单独说明。

7.1 不持有 token 的 store

web/src/lib/auth.svelte.ts 开头就写清了最终形态:

// Auth controller. The session lives in an httpOnly cookie the browser manages, // so there is no token here. The current user is resolved server-side in the // root layout load and exposed via `page.data.user` ... export function currentUser(): User | null { return page.data.user ?? null; } export function isAuthenticated(): boolean { return currentUser() !== null; }

它没有$state单例保存用户,而是读取page.data.user。原因写在注释里:模块级$state单例在 SSR 环境中会把一个请求的用户泄漏到另一个请求。这正是“布局层集成”在 SvelteKit 中的正确做法。

变更动作则统一为“调接口 +invalidateAll()”:

export async function login(email: string, password: string) { await api.login(email, password); await invalidateAll(); }

invalidateAll()触发根布局重新解析会话,从而让所有读取currentUser()的 UI 同步刷新。登出还额外清掉“最近一次认证”的本地提示:

export async function logout() { try { await api.logout(); } catch { /* ignore */ } forgetRecentAuthExpiry(); await invalidateAll(); }

注释解释了为什么这段清理不能省:最近认证的过期提示是按标签页记录的,同一标签页换人登录时,旧会话留下的“身份已确认”标记会变成错误信息。

7.2 服务端解析会话

web/src/routes/+layout.server.ts 在每个完整加载时解析用户,并转发请求携带的 Cookie。它先用hasSessionCookie判断请求是否真的带了会话 Cookie,再调用serverApi(fetch, cookie).me():

const SIGNED_OUT = { user: null, locale: 'en' as const };

解析失败一律落到SIGNED_OUT,不向用户抛错——这与规范中“启动时GET /me被拒绝就呈现未登录态且不报错”的场景要求完全一致。

Web 端的会话探测实现在 web/src/lib/authCookie.ts:它只判断 Cookie 头里是否存在名为hire_token的键值对,并且逐对解析而非子串匹配,避免not_hire_token=…被误判。文件注释说明了为什么必须检查这一个特定的 Cookie——分析工具在首次访问时也会写自己的 Cookie,“有 Cookie”几乎对所有人都成立。

7.3 API 客户端与登录页

web/src/lib/api.ts 中的认证函数保持极薄:

function register(email: string, password: string): Promise<User> { const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // 可能抛错则留空 return postAuth('/api/v1/auth/register', { email, password, timezone }); } function login(email: string, password: string): Promise<User> { return postAuth('/api/v1/auth/login', { email, password }); } async function logout(): Promise<void> { await call('/api/v1/auth/logout', { method: 'POST' }); } async function me(): Promise<User> { return requestData<User>('/api/v1/auth/me'); }

注意register会把浏览器检测到的 IANA 时区一并发上去;服务端对无法识别的时区采取“静默置空”而非拒绝注册(见accounts.Register中的if timezone != nil && !validTimezone(*timezone) { timezone = nil }),避免检测差异阻断注册。

界面侧,所有登录入口最终汇聚到 web/src/routes/signin/+page.svelte。该页面把“注册 / 登录 / 忘记密码 / 重置密码”四种屏幕编码进 URL hash(#register、#login、#forgot、#reset),同时用?mode=查询参数保证 SSR 阶段就能渲染正确表单;returnTo经safeRedirect限制为同源相对路径,防止开放重定向。

八、安全取舍与演进路径

提案的 Risks / Trade-offs 一节价值极高,它把每个“没做的事”都变成了有据可查的设计决策。下表把原始取舍与当前实现的对应关系列清楚:

原始风险当时的缓解当前实现
无令牌吊销控制 TTL、Cookie httpOnly增加token_version声明,每个受保护请求比对一次主键;LogoutAll递增版本号使全体会话失效
CSRFSameSite=Lax+ 同源部署,浏览器不在跨站请求上携带 Cookie保持一致;Cookie 分配逻辑按请求主机选择域(见CookieDomainForHost)
单一共享密钥空密钥时启动失败,强制显式配置增加 32 字节最小长度校验
登录无限流通用 401 + bcrypt 代价按 IP 限流(登录 10/min、注册 5/min)
无迁移执行器文档化“需重建开发库卷”迁移折叠为基线,由initdb首次初始化应用

关于“为什么选 Cookie 而不是localStorage+ Bearer”,设计文档的论证值得完整保留:HttpOnlyCookie 对 XSS 窃取免疫,而 JS 可读的 token 不是;由于 SPA 与 API 同源(开发用 Vite 代理,生产用反向代理),SameSite=Lax会在应用自身请求上发送 Cookie、在跨站请求上阻断,这本身就是 CSRF 防护,既不需要 CORSAllowCredentials,也不需要独立 CSRF token。若将来必须跨源,就只能退化为SameSite=None; Secure,那时才需要补 CSRF token。

关于“为什么仍用 JWT 而不是数据库会话”:无状态校验、无每请求查表、无需sessions表。代价是吊销困难——这正是后来token_version要解决的问题。设计文档还预留了一个优雅的接缝:令牌只携带sub(与登录方式无关),所以未来换成不透明会话 ID 也不会改动处理器。

OAuth 与 magic-link 的预留接缝

提案明确把这些列为 Non-Goals,但把数据模型调成了“纯增量可扩展”:

  • Google OAuth / 外部身份→ 未来新增user_identities表(user_id、provider、provider_user_id等),是“关联”而不是改users;
  • Magic link→ 未来新增短时效登录令牌表,无口令,正好落在可空的password_hash上;
  • 邮箱验证→ 未来增量增加email_verified列。

当前仓库已经沿着这条缝长出了 internal/identity/auth/oauth 与user_identities(可在 migrations/0001_init.sql 的外键定义中看到user_identities_user_id_fkey),这也反过来印证了当初留空password_hash的价值。

九、验证清单

提案的tasks.md给出了可复核的验收标准,其中 API 与 Web 两侧的手工 e2e 最值得照抄成回归用例:

API 侧:

  1. 注册 → 响应201且带Set-Cookie;
  2. 登录 → 响应200且带Set-Cookie;
  3. 携带 Cookie 请求GET /api/v1/auth/me→200;不带或使用过期 Cookie →401;
  4. POST /api/v1/auth/logout清除 Cookie,重复调用仍成功(幂等);
  5. 不带 Cookie 请求GET /api/v1/jobs仍然正常——证明认证层没有误伤公开读接口。

Web 侧:

  1. 从顶栏登录/注册后显示邮箱与登出;
  2. 刷新页面仍保持登录(Cookie 生效);
  3. 登出后回到登录/注册态;
  4. 篡改或过期 Cookie 时启动为未登录态且不报错。

单元测试证据(可在仓库中直接运行):

  • internal/identity/auth/password_test.go:TestHashPassword_RoundTrip、TestCheckPassword_RejectsWrong;
  • internal/identity/auth/token_test.go:TestIssuer_IssueParseRoundTrip、TestIssuer_RejectsExpiredToken、TestIssuer_RejectsWrongSignature、TestIssuer_IssuesDistinctSessionTokens、TestIssuer_LegacySessionCannotBindRecentAuth;
  • internal/identity/auth/middleware_test.go:中间件在各类失败路径上的 401 行为;
  • internal/platform/config/config_test.go:密钥与 TTL 的加载、默认值与回退逻辑。

完整构建与测试使用项目根目录的 Makefile 目标(go build ./... && go vet ./... && go test ./...,或对应的make任务),无需修改仓库任何文件。

十、小结:一次“只增不改”的认证落地

回看这次变更,它的可复用之处不在于某段代码,而在于一套决策顺序:

  1. 先定身份键——邮箱 +UNIQUE (lower(email)),让“一个账号多种登录方式”成为可能;
  2. 把安全原语与 HTTP 分层——bcrypt、JWT、Cookie 各自独立可测,处理器只剩编排;
  3. 选择与部署形态匹配的传输——同源 + httpOnly Cookie +SameSite=Lax,用部署方式换取 CSRF 防护与 XSS 免疫;
  4. 承认取舍并留好接缝——无吊销、无 CSRF token、无 OAuth,全部写进 Risks 与 Non-Goals,并用password_hash可空、令牌只含sub这两处“零成本预留”为下一轮迭代铺路;
  5. 公开接口零改动——认证是叠加而非替换,jobs与companies的读路径完全不受影响。

按这个顺序推进,你得到的不只是四个接口,而是一个可以持续承载收藏、投递、管理与第三方登录的身份底座。当后续需要引入自助吊销时,把token_version加进声明并做一次主键比对即可;当需要接入 OAuth 时,也只需新增身份关联表,而不必回头重写users。

  • 后端
  • 前端
  • 搜索引擎
  • 网页爬虫
  • 数据工程
  • AI 应用
  • 可观测性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载

相关推荐

上一篇:Obsidian 导出 PDF 终极增强:Better Export PDF 完整功能指南(预览+书签+页码一次讲透)
下一篇:只用"说话"就能训练AI智能体?OpenClaw-RL全异步强化学习框架初学者完整指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进