dual-auth-service-jwt-user-session

双层认证:服务 JWT 与用户会话

Profile 服务在每个请求上都要回答两个不同的问题,而且刻意把它们分开。第一个是“这个调用来自我信任的服务吗?”,第二个是“这个调用是代表哪个人在操作?”。前者由一枚服务令牌回答,后者由一个会话 cookie 回答——而且这个 cookie 要拿去问 Auth 服务才算数。两者互不替代:一个请求可以来自可信服务却不携带任何用户身份,代码把这两件事当作两个独立的事实,而不是揉成一个身份。

两个层次

第一层——服务信任。 一个 Fastify 插件在路由处理之前运行,检查 Authorization 头里的 bearer 令牌。这枚令牌是一个 JWT 形态的、经 HMAC 签名的紧凑令牌:由头部、载荷和签名三段组成,接收方用一个共享密钥重新计算签名并比对。验证还会检查约定的签名算法、可选的过期时间和签发时间,并留一个小的时钟偏移容差,好让服务之间略有漂移的时钟不至于把本来合法的令牌拒之门外。验证通过后,请求会被标记上一个服务身份(是哪个兄弟服务发起的调用)——而且很关键的一点是:这枚令牌里绝不读取任何终端用户信息。 插件自己的注释也写明了:抽取用户身份不是它的职责。

第二层——用户身份。 另一个 helper 从进来的请求里读出会话 cookie,然后调用 Auth 服务的“我是谁”接口,并把 cookie 转发过去——由 Auth 服务这个唯一掌握会话真相的组件来判定用户是谁。Profile 服务自己并不解码、也不信任会话,它只是去问。拿回来的是一小份用户上下文:一个 id、一个 email、一个用户类型(talent / employer / recruiter / admin),以及可选的姓名。那些还没选定用户类型的注册会被过滤掉,等同于“没有用户”。

这种拆分是单一真相源原则的干净落地:会话是否有效这件事只活在 Auth 服务里,其余每个服务都去查询它,而不是各存一份。代价是每个已认证请求多一次外呼,但换来的是:一个被吊销或被改动的会话,处处都立即生效,没有第二份存储会漂移。

角色守卫

用户上下文之上还叠着角色要求。“要求一个已登录用户”在会话无法解析时抛出未授权错误;“要求 talent / employer / recruiter / admin”则在解析出的用户类型不对时额外抛出禁止访问错误。认证(我们知道你是谁)和授权(你是否被允许)保持为两个独立的步骤,而不是搅在一起的一次检查。

例外:公开路由与公开资料路由

并不是每条路由都需要可信的调用方。健康检查、技能列表以及少数几个查询接口被声明为公开——在那里服务认证是可选的:有令牌就照样验证并附上,没有也不算错。第二类是公开资料读取,覆盖单个 talent、employer 或 recruiter 页面的 GET:这些无需可信服务令牌即可查看,好让任何人都能看到一份公开资料;而对应的“自己”(/me)路由则被明确排除在这份宽松之外,仍然受保护。凡不属于这两类的,一律要求一枚有效的服务令牌,缺失或无效的令牌会在进入处理器之前就被拒绝。

为什么是这个形状

这个服务位于网关之后、处在一簇兄弟服务当中,所以“调用方是可信的基础设施”和“用户是某某人”确实是两个独立的事实,它们经由不同的通道抵达——一个是调用方设置的请求头,一个是为浏览器设置的 cookie。把两者混为一谈会逼出两种糟糕结局之一:要么信任烤进服务令牌里的用户声明(那样任何服务都能冒充任何用户),要么让每一次公开资料的查看都要求服务级凭据。让两层保持正交,每条路由才能恰好挑到它需要的那种组合。

本节内容

本节只覆盖 profile 服务的认证边界。相关节点:可与 standmeet 项目自身的访问模型对照,以及本服务的 talent 解锁与配额 门禁——那正是这些已认证路由最终要保护的东西。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

dual-auth-service-jwt-user-session