☰
Penpot 用户认证体系深度解析:注册、登录、OIDC/LDAP 后端与会话全流程
2026/11/4 5:11:57 网站建设 项目流程

Penpot 用户认证体系深度解析:注册、登录、OIDC/LDAP 后端与会话全流程

【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpot

Penpot 的身份认证并不是单一的一对用户名密码校验,而是一套由多个"认证后端(authentication backends)"构成的插件化体系:内置邮箱密码、外部 LDAP 目录、以及基于 OpenID Connect 协议的 OIDC / Google / GitHub / GitLab 社交登录。本文以 docs/technical-guide/developer/subsystems/authentication.md 为骨架,结合当前仓库中认证相关源码,完整梳理从注册、邮箱验证、登录到会话创建、团队邀请、退信处理与 Devenv 本地测试的端到端流程,帮助你理解 Penpot 的认证架构并在自托管部署中正确配置与排查。

认证后端总览:一种抽象,多种实现

Penpot 将身份来源抽象为若干可插拔的"认证后端",可以在实例配置中分别启用:

  • penpot:平台内置注册,使用邮箱 + 密码在 Penpot 自己的数据库中建档;
  • ldap:通过外部 LDAP 目录进行认证,用户目录由 Penpot 之外的系统维护;
  • oidc、google、github、gitlab:基于 OpenID Connect 协议的外部服务认证。其中google/github/gitlab是已经预配置好协议端点的"知名服务",而oidc是一个通用处理器,可对接任意符合 OIDC 标准的外部身份服务。

当前仓库中的代码映射

原文档给出的核心文件清单(backend/src/app/rpc/mutations/...)对应的是较早时期的源码布局。在当前仓库中,RPC 层已经从mutations重构成了commands目录,认证相关职责拆分得更清晰,逐一对应如下:

原文档所列职责当前仓库中的实现
注册资料创建 /register-profilebackend/src/app/rpc/commands/auth.clj
个人资料管理 / 登录会话backend/src/app/rpc/commands/profile.clj
LDAP 登录backend/src/app/rpc/commands/ldap.clj
多种 token 的验证路由(verify-token)backend/src/app/rpc/commands/verify_token.clj
OIDC 认证后端(含知名服务与通用发现协议)backend/src/app/auth/oidc.clj
LDAP 连接与认证底层backend/src/app/auth/ldap.clj
会话创建 / 存储 / Cookie 签发backend/src/app/http/session.clj
前端 token 验证视图(邮箱验证 / 改邮箱 / 邀请)frontend/src/app/main/ui/auth/verify-token.cljs

有一点值得留意:用户档案在数据库中会记录其首次注册所使用的认证后端(auth-backend字段),主要用于审计。只要邮箱一致,用户后续允许通过其他后端登录同一账号。

统一的设计原则:复用register-profile与登录逻辑

不同认证后端的"长相"差别很大(内置表单、重定向到第三方、LDAP 绑定),但 Penpot 在代码组织上刻意追求复用:所有注册路径最终都汇入同一个register-profile服务,由它统一负责在数据库创建用户档案;所有登录路径最终都汇入同一套会话创建逻辑。这使得新增一个认证后端时,只需要实现"获取身份信息"这一段,建户与开会话的部分基本不用改动。

Penpot 内置后端:两步式注册

内置注册采用"两步提交"流程,全部经由 RPC 层(backend/src/app/rpc/commands/auth.clj):

  1. 用户在第一步注册页输入邮箱和密码后,前端调用:prepare-register-profile。该方法不落库,而是把邮箱、全名、密码等注册数据打包生成一个临时的 JWT 类型 token(register token),claims 中iss为:prepared-register,有效期默认为 7 天(见源码中:exp (ct/in-future {:days 7})一行),随后把 token 交给第二页。

  2. 第二步注册页调用:register-profile,携带上一步的 token 与其余档案数据(如是否接受新闻邮件、是否创建欢迎文件等)。服务端校验该 token(tokens/verify且要求iss :prepared-register)后:

    • 若邮箱尚不存在,则创建档案并挂载默认团队/项目关系;
    • 若邮箱已存在,则进入"重复注册"分支:若距上次尝试已超过重试阈值且该邮箱没有退信/投诉记录,则向该邮箱重发验证邮件;
    • 若档案处于 blocked 状态则直接忽略并记录审计日志。

创建档案的核心函数是create-profile(同文件 auth.clj),它从参数中解析auth-backend(默认"penpot")、is-active、is-demo等字段后写入profile表,并将初次看到的引导状态、步进值(nudge)等写入props。这里还顺带处理了邮箱大小写归一化(str/lower)与重复邮箱冲突(捕获 PostgreSQL 唯一键冲突并返回email-already-exists)。

注册完成后是否直接可用,取决于:email-verification功能开关:

  • 开启邮箱验证时:新档案初始为 inactive,系统通过send-email-verification!发送一封含验证链接的确认邮件;
  • 关闭邮箱验证时:档案直接以 active 状态创建,注册即登录;
  • 来自团队邀请的注册:如果 token 中携带invitation-token,则把它内嵌进验证邮件的 JWE 中,用户点击邮件链接验证后即可继续完成邀请加入流程(详见"团队邀请"一节)。

邮箱验证:/auth/verify-token前端视图

确认邮件中的链接指向/auth/verify-token。该前端视图是一个多种 token 的汇聚处理站,用于处理注册验证邮件、邮箱更换与团队邀请链接等不同场景。它调用后端:verify-tokenRPC(backend/src/app/rpc/commands/verify_token.clj),并根据 token 类型把用户重定向到对应页面并展示结果。

后端verify-token通过process-token多方法按iss(token 签发用途)分发处理,当前仓库至少覆盖以下类型:

  • :verify-email:激活档案并创建会话,若验证 token 中携带了邀请信息,则在验证后把邀请流程继续推进;
  • :change-email:完成邮箱更换;
  • :team-invitation:处理团队邀请 token(见下)。

在源码中可以看到:verify-token处理结束后普遍会执行session/create-fn来直接为用户建立会话,从而做到"点邮件链接即登录"。

登录

使用 penpot 内置后端时,用户在登录页输入邮箱与密码,前端把凭据提交给:login方法。校验通过后即为用户创建会话;校验失败则按具体原因返回错误(如凭据错误、档案被屏蔽等)。

OIDC 认证后端:从"Login with XXX"按钮到回调

当用户点击"Log in with Google / GitHub / GitLab / OIDC"按钮时,前端调用/auth/oauth/:provider(provider 即google、github、gitlab或oidc),后端处理器会生成一个request token并把浏览器重定向到服务提供商进行认证。

认证成功后,提供商把用户重定向回/auth/oauth/:provider/callback。后端在此校验 request token、从认证响应中提取access token,再用该 token 向提供商请求用户的邮箱与全名。

接下来与 LDAP 分支的逻辑一致:

  • 若邮箱对应的档案已存在,则直接创建会话登录;
  • 若不存在,则调用:register-profile在系统中创建新用户。

OIDC 的配置实现集中在 backend/src/app/auth/oidc.clj。对于 google/github/gitlab 等知名服务,协议端点的地址(授权端点、token 端点等)是硬编码在仓库里的;而对于通用 OIDC 服务,则有两种获取端点的方式:

  1. 利用 OIDC发现协议(discovery)向提供商查询;
  2. 由系统管理员通过配置变量手动指定。

从get-oidc-config的源码可以看到后端读取的配置项集合(前文为 backend/src/app/auth/oidc.clj 中的相关函数):

配置项含义
oidc-base-uri/oidc-client-id/oidc-client-secret提供商入口与客户端凭据
oidc-auth-uri/oidc-token-uri/oidc-user-uri授权、换取 token、获取用户信息的端点
oidc-jwks-uri用于校验 ID token 签名的 JWKS 端点
oidc-scopes请求的 scope 集合(有默认值)
oidc-roles/oidc-roles-attr角色映射与角色属性名(用于更细粒度的访问控制场景)
oidc-user-info-source用户信息获取方式
oidc-email-attr/oidc-name-attr用户信息中邮箱/姓名字段的属性名,默认分别为"email"与"name"

值得注意的是,OIDC 回调链路中也会根据功能开关判断是否允许注册(例如代码中存在registration-disabled时携带错误重定向的分支),因此"仅允许现有成员登录、不允许自助注册"这类策略可以通过实例级 flag 统一控制。

LDAP 后端:只认证、不建档于外部目录

LDAP 的设计前提与内置后端有本质区别:用户目录由 Penpot 之外的 LDAP 服务器维护,因此不能通过 LDAP 完成注册。实践中,一旦启用 LDAP 登录,通常同时禁用平台自带的邮箱密码登录。

用户输入用户名与密码并点击 "Login with LDAP" 后,前端调用:login-with-ldap(RPC 定义在 backend/src/app/rpc/commands/ldap.clj)。该服务通过app.auth.ldap(backend/src/app/auth/ldap.clj)连接 LDAP 服务校验凭据,并从目录中取回邮箱与全名。在此过程中有三个典型错误分支:

  • 后端未初始化 LDAP provider 时返回ldap-not-initialized;
  • LDAP 凭据校验失败返回wrong-credentials;
  • 档案处于屏蔽状态返回profile-blocked。

身份校验成功后,服务端的login-or-register与 OIDC 分支共用同一策略:先按邮箱在本地档案库查找(get-profile-by-email),找到则登录;找不到则调用create-profile直接以该邮箱创建本地档案(is-active true)——也就是说,LDAP/OIDC 登录本质上是一次"隐式注册",档案只是作为本地投影存在。如果请求同时携带团队邀请 token,login-with-ldap还会重签邀请 token 并把它与新建会话一起返回前端,以便随后完成加入团队的动作。

会话(Session):从登录到 Cookie

无论通过哪个后端登录,最终都会进入会话创建逻辑(backend/src/app/http/session.clj):

  1. 生成一个会话 token:它是一个 JWT,且当前刻意不承载业务数据(iss为"authentication"之类的仅用于校验的声明);
  2. 把 token 以Cookie形式返回前端;
  3. 服务端记录会话与档案、过期时间的对应关系。

会话的持久化位置取决于运行模式:

  • 常规模式:会话存放在数据库表中,记录用户档案与过期信息,重启后端后会话仍然有效;
  • 只读模式:当前端以 "read only" 方式连接后端时(典型场景是本地 Devenv 前端直连生产后端做问题排查),会话改为保存在内存中——这意味着后端一旦重启,所有会话都会丢失。

从源码还能看到会话层附带的后勤机制:Cookie 的过期时间由配置项auth-token-cookie-max-age控制(存在默认值);存在会话续期逻辑(renew-session?),也提供invalidate-others、invalidate-all等主动失效手段,用于账号在别处登录或安全事件下的会话回收。

团队邀请:验证邮件与邀请 token 的衔接

团队邀请链接同样指向前面介绍的/auth/verify-token前端视图,token 中携带被邀请的邮箱地址。用户点开链接后:

  1. 后端验证邀请 token;
  2. 根据该邮箱是否已有账号进行路由:
    • 已有账号:走:login/ 既有会话流程;
    • 没有账号:走:register-profile创建新档案;
  3. 邀请 token 被附加到流程中,使得新档案在流程结束时与团队正确关联。

邀请接受的核心实现位于 backend/src/app/rpc/commands/verify_token.clj 的process-token :team-invitation分支:它会校验 token 载荷、确认当前登录用户与邀请邮箱一致、检查邀请是否仍有效(若邀请已被取消则返回invalid-token/canceled-invitation类错误)、然后以事务方式更新成员角色并删除已使用的邀请记录(包括兼容历史邀请格式的处理)。另外,register-profile(auth.clj)在"新建档案"分支中会特别判断档案是否为 active:若新档案立刻可激活且邀请匹配,则创建会话并把邀请 token 交还前端,让前端立即跳转完成邀请;若新档案还需邮箱验证,则把邀请 token 内嵌到验证邮件中,形成"注册 → 验证 → 自动加入团队"的完整闭环。

未完成注册与"弹回"邮箱的处理

认证链路里出现的所有 token 都带过期时间。对于需要持久化存放的 token,系统中有垃圾回收(garbage collector)任务定期访问存储并清理过期项,避免残留脏数据。

邮件层面,Penpot 的邮件服务(文档指向backend/src/app/emails.clj,当前仓库中邮件模板/发送相关工具函数以app.emails命名空间组织)会登记退信(bounce)与垃圾投诉(spam complaint)报告。当一个邮箱收到的通知类邮件过多、触发退信或投诉阈值后:

  • 该邮箱对应的档案会被标记为blocked;
  • 此后该用户无法用此邮箱登录或注册,系统也不再向其发送任何邮件;
  • 若日后该邮箱恢复,需要在数据库中人工解锁。

在注册/恢复密码的代码路径中可以看到这套策略被严格执行:register-profile对 blocked 档案直接忽略(仅返回基础信息并写审计日志),密码找回(request-profile-recovery)也会检查allow-send-emails?、退信与投诉报告后再决定是否发信,避免对"问题邮箱"持续投递。

在 Devenv 中测试认证全流程

技术指南提供了完整的本地实验方案,方便开发者在无需真实第三方账号的情况下验证整套认证逻辑。

基础注册与邮件链路:Mail Catcher

要测试常规的注册流程(含确认邮件),直接使用 Devenv 自带的Mail Catcher(邮件捕获器)工具即可,无需配置真实 SMTP。你可以在 Devenv 文档的 Email 小节 找到启用方式。所有发出的注册确认邮件都会出现在该工具里,点开其中的验证链接即可走完注册。

测试 OIDC:在真实服务商注册应用

要端到端测试 OIDC 登录,需要先在某一个服务商处注册一个 OAuth/OIDC 应用,官方指南针对各服务商提供了对应的文档(Github、Gitlab、Google 各自的应用创建页)。

应用的回调/应用 URL填 Devenv 前端地址:

http://localhost:3449

随后把应用的客户端凭据分别写入两个位置:

  • 后端 REPL 侧:backend/scripts/repl;
  • 前端运行时配置:frontend/resources/public/js/config.js。

需要说明的是,config.js并不是仓库中提交的静态文件,而是前端运行时注入的全局配置。前端在启动时(frontend/src/app/config.cljs)会从全局对象读取penpotFlags字符串,按空白分词后转换成关键字集合,作为当前可用的功能开关集合。因此你可以按如下方式在配置文件中声明要启用的特性。

测试 LDAP:内置测试服务器

Devenv 中内置了一个预配置的测试 LDAP 服务器(基于 docker-test-openldap 镜像),无需额外搭建。启用方式是在前端config.js中开启对应开关,文档给出的最小示例为:

var penpotFlags = "enable-login-with-ldap";

该字符串被前端解析为:enable-login-with-ldap功能 flag(解析逻辑见 frontend/src/app/config.cljs 的parse-flags),登录页便会据此显示 "Login with LDAP" 入口。相应的,前端的登录数据流(frontend/src/app/main/data/auth.cljs)会调用:login-with-ldapRPC,并在成功后建立带login-with-ldap来源标记的登录事件。

相关后端 flag 一览

除 LDAP 开关外,阅读后端认证源码时还会频繁遇到这些影响认证行为的 flag,例如:

  • :email-verification:关闭后注册档案直接激活、无需邮箱验证(此时已存在但未激活的同邮箱重复注册会被拒绝,因为邮箱已被占用);
  • :registration:影响邀请/注册后的落地路由(无邀请或禁止注册时重定向到登录页);
  • :admin-console(配套组织邀请场景):决定组织级邀请是否可用。

这些 flag 与 LDAP provider 的初始化状态(未初始化时login-with-ldap会直接报ldap-not-initialized)共同决定了实例对外呈现的认证能力。

小结:一次认证请求的生命周期

综合全文,可以把 Penpot 中一次"第三方登录 + 首次注册"的完整旅程归纳为:

  1. 前端发起/auth/oauth/:provider或/auth/verify-token等入口;
  2. 后端经 OIDC 协议 / LDAP 绑定 / 内置密码校验获得用户身份(邮箱 + 全名);
  3. 按邮箱查找既有档案:命中则直接进入会话创建;未命中则调用统一的register-profile落库(记录auth-backend供审计);
  4. session/create-fn生成 JWT 会话 token,以 Cookie 回写前端;
  5. 若涉及邮箱验证或团队邀请,则通过verify-token前端视图与process-token多方法完成激活与入团闭环;
  6. 全程受 token 过期、垃圾回收任务、退信/投诉阻断与 blocked 档案机制约束。

理解这条链路的每一环——尤其是各后端共用register-profile、会话的库/内存两种存储模式,以及邀请 token 内嵌验证邮件的设计——对自托管部署时配置 LDAP/OIDC、排查"注册后收不到邮件""受邀用户无法加入团队""会话重启即失效"等问题都很有帮助。更完整的相关源码可从 backend/src/app/rpc/commands/auth.clj、backend/src/app/auth/oidc.clj、backend/src/app/http/session.clj 与 frontend/src/app/main/ui/auth 继续深入阅读。

【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpot

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询