双层认证:服务 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 解锁与配额 门禁——那正是这些已认证路由最终要保护的东西。