后端前端搜索引擎网页爬虫数据工程AI 应用可观测性【免费下载链接】freehirefreehire — 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认证机制无状态 JWTHS256签发于注册/登录由 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三个设计点值得逐条展开password_hash可空。这不是疏忽。变更明确宣布下一轮迭代要做 Google OAuth 与 magic-link 登录而这类用户根本没有口令。若此刻写成NOT NULL未来要放宽就必须再写一条迁移而项目当时还没有迁移执行器migration runner修改0005不会对已存在的数据库卷重新生效线上库做NOT NULL → NULL变更相当痛苦。现在留空是零成本的。UNIQUE (lower(email))而非UNIQUE (email)。用函数索引把大小写归一化交给数据库兜底应用层再把邮箱统一转小写存储两者配合实现“一个邮箱一个账号任意大小写都算重复”。邮箱是唯一身份键。这是“一个账号、多种登录方式”的地基今天是密码将来 Google 与 magic-link 都归并或关联到同一个以邮箱为键的用户上。JWT 里只携带 user idsub因此天然与登录提供方无关。3.2 当前仓库中的实际形态仓库后来把历史迁移折叠成单一基线 migrations/0001_init.sqlusers表就落在其中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返回体天然不含口令哈希GetUserByEmailWHERE 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 口令哈希bcryptinternal/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 令牌签发Issuerinternal/identity/auth/token.go 用一个Issuer统一封装密钥与 TTLtype 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 的 TTLiat签发时间jti16 字节随机数的 base64url 编码标识“这一次会话”tv账号的token_version指针类型缺失与 0 可区分Parse的校验顺序与安全意图值得完整引用其行为通过jwt.ParseWithClaims的回调强制校验签名算法属于 HMAC任何非 HMAC 的alg都会报unexpected signing method从而挡住算法混淆algorithm confusion攻击校验签名与过期时间由库完成sub必须能解析为int64否则返回invalid token subjecttv缺失时返回专用错误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、SameSiteLax、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.gofunc 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数据库故障 → 503OptionalCookieAuth有有效 Cookie 就附加身份否则匿名放行不拒绝用于 OAuth 回调等顶层导航OptionalAuthCookie 或 API key 有效则附加身份否则匿名公开读接口上叠加“我的投票”等个性化信息RequireAuthOrKey/RequireAuthOrScopedKeyCookie 优先其次Authorization: Bearer凭据会话 JWT 或 API key两者都不成立 → 401key 作用域不足 → 403RequireRole认证之后按数据库中的role授权无身份 → 401角色不符 → 403三个实现细节直接对应提案的安全目标身份注入用LocalsUserID auth.userID。之所以导出这个键是因为 WebSocket 处理器拿到的是Conn而不是Ctx只能按字符串键读取继承的 local。会话校验不止验签。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清除会话 CookieGET/api/v1/auth/me会话 Cookie 或 API key200返回当前用户整个/auth分组挂了一条noCache中间件统一写入Cache-Control: no-store与Pragma: no-cache避免任何代理或浏览器缓存凭据类响应。5.2 请求与响应示例注册请求体credentials结构体{ email: aliceexample.com, password: correct horse battery staple, timezone: Europe/Berlin }timezone是可选字段由浏览器Intl.DateTimeFormat().resolvedOptions().timeZone自动带入仅在注册时使用登录会忽略它避免每次登录静默覆盖用户在资料页设置的时区。成功响应统一包在{data: ...}信封中这是项目既有约定{ data: { id: 42, email: aliceexample.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 emailErrPasswordTooShort400password must be at least 8 charactersErrPasswordTooLong400password must be at most 72 charactersErrEmailTaken409email already registeredErrInvalidCredentials401invalid credentialsErrUserNotFound401unauthorized基础设施错误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) }注意这里只清 CookieJWT 本身在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_TTL720h30 天用envDuration解析无法解析时回退到默认值COOKIE_SECUREfalseENVproduction或前端源为https://时必须为trueCOOKIE_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_TTL24h即可——这也解释了为什么登出之外的吊销能力token_version在实现中变得重要。6.2 迁移与代码生成提案的迁移计划给出了可执行的落地顺序与仓库约定一致新增建表迁移由于当时没有迁移执行器只能通过重建开发库卷来应用docker compose down -v make up。加依赖、写users.sql查询、执行make sqlc并提交生成的代码。先实现internal/auth再写处理器最后在handler.Register中接线。运行服务前设置JWT_SECRETHTTPS 部署同时设置COOKIE_SECUREtrue。开发环境下 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 的 storeweb/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): PromiseUser { const timezone Intl.DateTimeFormat().resolvedOptions().timeZone; // 可能抛错则留空 return postAuth(/api/v1/auth/register, { email, password, timezone }); } function login(email: string, password: string): PromiseUser { return postAuth(/api/v1/auth/login, { email, password }); } async function logout(): Promisevoid { await call(/api/v1/auth/logout, { method: POST }); } async function me(): PromiseUser { return requestDataUser(/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递增版本号使全体会话失效CSRFSameSiteLax 同源部署浏览器不在跨站请求上携带 Cookie保持一致Cookie 分配逻辑按请求主机选择域见CookieDomainForHost单一共享密钥空密钥时启动失败强制显式配置增加 32 字节最小长度校验登录无限流通用 401 bcrypt 代价按 IP 限流登录 10/min、注册 5/min无迁移执行器文档化“需重建开发库卷”迁移折叠为基线由initdb首次初始化应用关于“为什么选 Cookie 而不是localStorage Bearer”设计文档的论证值得完整保留HttpOnlyCookie 对 XSS 窃取免疫而 JS 可读的 token 不是由于 SPA 与 API 同源开发用 Vite 代理生产用反向代理SameSiteLax会在应用自身请求上发送 Cookie、在跨站请求上阻断这本身就是 CSRF 防护既不需要 CORSAllowCredentials也不需要独立 CSRF token。若将来必须跨源就只能退化为SameSiteNone; Secure那时才需要补 CSRF token。关于“为什么仍用 JWT 而不是数据库会话”无状态校验、无每请求查表、无需sessions表。代价是吊销困难——这正是后来token_version要解决的问题。设计文档还预留了一个优雅的接缝令牌只携带sub与登录方式无关所以未来换成不透明会话 ID 也不会改动处理器。OAuth 与 magic-link 的预留接缝提案明确把这些列为 Non-Goals但把数据模型调成了“纯增量可扩展”Google OAuth / 外部身份→ 未来新增user_identities表user_id、provider、provider_user_id等是“关联”而不是改usersMagic 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 侧注册 → 响应201且带Set-Cookie登录 → 响应200且带Set-Cookie携带 Cookie 请求GET /api/v1/auth/me→200不带或使用过期 Cookie →401POST /api/v1/auth/logout清除 Cookie重复调用仍成功幂等不带 Cookie 请求GET /api/v1/jobs仍然正常——证明认证层没有误伤公开读接口。Web 侧从顶栏登录/注册后显示邮箱与登出刷新页面仍保持登录Cookie 生效登出后回到登录/注册态篡改或过期 Cookie 时启动为未登录态且不报错。单元测试证据可在仓库中直接运行internal/identity/auth/password_test.goTestHashPassword_RoundTrip、TestCheckPassword_RejectsWronginternal/identity/auth/token_test.goTestIssuer_IssueParseRoundTrip、TestIssuer_RejectsExpiredToken、TestIssuer_RejectsWrongSignature、TestIssuer_IssuesDistinctSessionTokens、TestIssuer_LegacySessionCannotBindRecentAuthinternal/identity/auth/middleware_test.go中间件在各类失败路径上的 401 行为internal/platform/config/config_test.go密钥与 TTL 的加载、默认值与回退逻辑。完整构建与测试使用项目根目录的 Makefile 目标go build ./... go vet ./... go test ./...或对应的make任务无需修改仓库任何文件。十、小结一次“只增不改”的认证落地回看这次变更它的可复用之处不在于某段代码而在于一套决策顺序先定身份键——邮箱 UNIQUE (lower(email))让“一个账号多种登录方式”成为可能把安全原语与 HTTP 分层——bcrypt、JWT、Cookie 各自独立可测处理器只剩编排选择与部署形态匹配的传输——同源 httpOnly Cookie SameSiteLax用部署方式换取 CSRF 防护与 XSS 免疫承认取舍并留好接缝——无吊销、无 CSRF token、无 OAuth全部写进 Risks 与 Non-Goals并用password_hash可空、令牌只含sub这两处“零成本预留”为下一轮迭代铺路公开接口零改动——认证是叠加而非替换jobs与companies的读路径完全不受影响。按这个顺序推进你得到的不只是四个接口而是一个可以持续承载收藏、投递、管理与第三方登录的身份底座。当后续需要引入自助吊销时把token_version加进声明并做一次主键比对即可当需要接入 OAuth 时也只需新增身份关联表而不必回头重写users。赞分享后端前端搜索引擎网页爬虫数据工程AI 应用可观测性【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载相关推荐freehire 用户认证设计解析HS256 JWT 与 HttpOnly Cookie 无状态会话落地实践freehire 用户认证设计解析HS256 JWT 与 HttpOnly Cookie 无状态会话落地实践 本文以 freehire 归档设计文档 open后端前端搜索引擎网页爬虫数据工程AI 应用可观测性freehire 用户认证体系实战从 tasks.md 到 bcrypt、JWT Cookie 与 Svelte SPA 的完整落地freehire 用户认证体系实战从 tasks.md 到 bcrypt、JWT Cookie 与 Svelte SPA 的完整落地 本文以 freehire后端前端搜索引擎网页爬虫数据工程AI 应用可观测性OpenPencil 实时协作指南基于 WebRTC CRDT 的无服务器多人协同编辑OpenPencil 实时协作指南基于 WebRTC CRDT 的无服务器多人协同编辑 OpenPencil 是一款 AI native 设计编辑器开源后端前端搜索引擎网页爬虫数据工程AI 应用可观测性上一篇Obsidian 导出 PDF 终极增强Better Export PDF 完整功能指南预览书签页码一次讲透下一篇只用说话就能训练AI智能体OpenClaw-RL全异步强化学习框架初学者完整指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
阅读完成 · 觉得有帮助?