SSO 与外部身份集成
OIDC、OAuth2、LDAP、CAS、钉钉与飞书登录,以及把外部组映射到角色。
这页办完你会得到:用公司账号就能登进夜莺,账号第一次登录时自动创建, 并且落在一个看得见东西的角色和团队上——最后这半句是接 SSO 最常翻车的地方。
六个身份源,两种配置方式
系统配置 → 单点登录(/system/sso-settings),六个 tab:LDAP、CAS、OIDC、OAuth2、钉钉、飞书。
编辑方式看 tab:
- LDAP / CAS / OIDC / OAuth2:一整块 TOML 文本,在代码编辑器里直接改,字段名就是 TOML 的键名;
- 钉钉 / 飞书:常规表单。

两种都是底部一个 保存。保存后不用重启进程——后端定期比对配置的更新时间,
变了就重新加载一次。保存要求 Admin;/system/sso-settings 那个权限点只管菜单显不显示,
见角色与权限矩阵。
配置正文里可以用 {{.变量名}} 引用加密变量,六个身份源都支持。
ClientSecret、BindPass 这类值不想以明文留在库里就走这条路,见密钥管理。
1. 接一个 OIDC
把 OIDC tab 里的 Enable 改成 true,填下面几项:
Enable = true
DisplayName = 'Sign in with OIDC'
RedirectURL = 'https://<你的夜莺地址>/callback'
SsoAddr = 'https://sso.example.org'
SsoLogoutAddr = 'https://sso.example.org/session/end'
ClientId = '<IdP 那边分配的>'
ClientSecret = '{{.oidc_client_secret}}'
CoverAttributes = true
DefaultRoles = ['Standard']
Scopes = ['openid', 'profile', 'email', 'phone']
[Attributes]
Username = 'sub'
Nickname = 'nickname'
Phone = 'phone_number'
Email = 'email'
几个要点:
RedirectURL必须和 IdP 那边登记的回调地址一字不差,路径固定是/callback。[Attributes]是「IdP 的 claim 叫什么」到夜莺字段的对应。Username决定登录名, 挑一个不会变的 claim——sub比email稳,因为登录名建完就改不了。CoverAttributes = true表示每次登录都用 IdP 的值覆盖夜莺里的显示名、手机号、邮箱。DefaultRoles只在第一次登录建账号时生效。
预期结果:保存后回登录页,多出一个写着 DisplayName 文案的按钮。点它跳到 IdP,
认证完跳回夜莺并且已经是登录状态。
2. 接一个通用 OAuth2
身份源只支持普通 OAuth2、不支持 OIDC 时用这个 tab——典型的是公司自建、带自己一套用户信息接口的 SSO。 身份源支持 OIDC 的话,优先用 OIDC tab。
Enable = true
DisplayName = 'Sign in with OAuth2'
RedirectURL = 'https://<你的夜莺地址>/callback/oauth'
SsoAddr = 'https://sso.example.com/oauth2/authorize'
SsoLogoutAddr = 'https://sso.example.com/oauth2/logout'
TokenAddr = 'https://sso.example.com/oauth2/token'
UserInfoAddr = 'https://sso.example.com/api/v1/user/info'
TranTokenMethod = 'header'
ClientId = '<身份源分配的>'
ClientSecret = '{{.oauth2_client_secret}}'
CoverAttributes = true
DefaultRoles = ['Standard']
DefaultTeams = [1]
UserinfoIsArray = false
UserinfoPrefix = 'data'
Scopes = ['profile', 'email', 'phone']
[Attributes]
Username = 'sub'
Nickname = 'nickname'
Phone = 'phone_number'
Email = 'email'
一次登录走四步,上面每个地址各管一步:
- 浏览器跳到
SsoAddr(授权端点),带上 client ID、Scopes、一个 state 值和RedirectURL; - 身份源把浏览器带着 code 送回
RedirectURL。路径固定是/callback/oauth, 必须和身份源那边登记的完全一致; - 后端拿
ClientId、ClientSecret去TokenAddr用 code 换 access token; - 后端带着这个 token 调
UserInfoAddr,按[Attributes]映射返回结果。
state 值在 Redis 里只存 5 分钟,所以一次登录要在 5 分钟内走完。
| 字段 | 说明 |
|---|---|
TranTokenMethod | 调 UserInfoAddr 时 token 怎么传。header(默认):GET,带 Authorization: Bearer <token> 和一个 client_id 请求头;querystring:GET,?access_token=…&client_id=…,同时也带 Bearer 头;formdata:POST,表单里放 access_token 和 client_id |
UserinfoPrefix | 用户对象在响应里的位置。字段就在最外层时留空。支持 data.user 这样的点路径 |
UserinfoIsArray | 用户对象是数组第一个元素时设为 true |
[Attributes] | 用户对象里的字段名。Username 是登录名,建出来以后改不了,挑一个稳定的 |
DefaultRoles / DefaultTeams | 只在第一次登录建账号时生效。DefaultTeams 填团队 ID |
SsoLogoutAddr | 退出夜莺后浏览器跳去的地址;留空就只退出夜莺本身 |
SkipTlsVerify | 模板里没有;身份源是自签证书时加一行 SkipTlsVerify = true |
对着真实响应看 UserinfoPrefix 和 UserinfoIsArray 怎么填:
| 用户信息接口的响应 | 配置 |
|---|---|
{"sub": "alice", "email": "…"} | UserinfoPrefix = '' |
{"code": 0, "data": {"sub": "alice"}} | UserinfoPrefix = 'data' |
{"data": [{"sub": "alice"}]} | UserinfoPrefix = 'data',UserinfoIsArray = true |
{"data": {"user": {"sub": "alice"}}} | UserinfoPrefix = 'data.user' |
保存之前,拿一个真实 token 手动调一次 UserInfoAddr:前缀或字段名对不上时不会报错,只会取到空值。
RSVerifyMethod、IntrospectAddr、IntrospectCacheSeconds 和浏览器登录无关,
只在这个身份源同时用来校验 A2A、MCP 的 access token 时生效,见OAuth 2.1 与外部身份源。
3. 接 CAS
Enable = true
DisplayName = 'Sign in with CAS'
RedirectURL = 'https://<你的夜莺地址>/callback/cas'
SsoAddr = 'https://cas.example.com/cas'
LoginPath = '/login'
SsoLogoutAddr = 'https://cas.example.com/cas/logout'
CoverAttributes = true
DefaultRoles = ['Standard']
[Attributes]
Nickname = 'displayName'
Phone = 'mobile'
Email = 'mail'
有两个地址是从 SsoAddr 推出来的,把它们弄对就成功了一大半:
| 环节 | 地址 |
|---|---|
| 登录页 | SsoAddr + LoginPath,后面带 ?service=<RedirectURL> |
| 票据校验 | SsoAddr + /serviceValidate(CAS 2.0 协议) |
按上面的例子,就是 https://cas.example.com/cas/login 和 https://cas.example.com/cas/serviceValidate。
LoginPath显式写上。 留空时会在SsoAddr后面拼/cas/login——SsoAddr里含p3时拼/login。 模板里示例的SsoAddr本身就以/cas/结尾,LoginPath留空就会拼出/cas//cas/login。RedirectURL就是 CAS 服务端看到的service,路径固定是/callback/cas。 很多 CAS 服务端只接受在服务注册表里登记过的 service。- 登录名是 CAS 的 principal——校验响应里
<cas:user>的值。[Attributes]里的Username对 CAS 不起作用。 - 显示名、手机号、邮箱从
<cas:attributes>里取。取到空值,说明 CAS 服务端没在 CAS 2.0 校验接口上释放属性,去查它对这个 service 的属性释放策略。 - CAS 没有
DefaultTeams。新建的 CAS 账号只有DefaultRoles、没有团队,事后要手动分配—— 见第 6 节说的那个坑。 - CAS 服务端是自签证书时,加一行
SkipTlsVerify = true。
4. 接 LDAP
LDAP 不走浏览器跳转:用户在夜莺自己的登录框里输 LDAP 的账号密码, 后端拿这对凭据去 LDAP 验一次。所以它没有回调地址。
Enable = true
Host = 'ldap.example.org'
Port = 389
BaseDn = 'dc=example,dc=org'
BindUser = 'cn=manager,dc=example,dc=org'
BindPass = '{{.ldap_bind_pass}}'
AuthFilter = '(&(uid=%s))'
UserFilter = '(&(uid=*))'
CoverAttributes = true
CoverRoles = false
TLS = false
StartTLS = true
DefaultRoles = ['Standard']
SyncAddUsers = false
SyncDelUsers = false
SyncInterval = 86400
[Attributes]
Username = 'uid'
Nickname = 'cn'
Phone = 'mobile'
Email = 'mail'
AuthFilter里的%s会被替换成登录名。OpenLDAP 通常是(&(uid=%s)), AD 是(&(sAMAccountName=%s))。BaseDn支持用|分隔写多个。TLS是 ldaps(要换成 636 端口),StartTLS是在 389 上升级,两者选一个。SyncAddUsers/SyncDelUsers打开后,按SyncInterval(秒)定期把UserFilter命中的人同步进来、把不在了的清掉。
LDAP 是六个里唯一能把外部组映射成角色和团队的。 追加若干段 [[RoleTeamMapping]]:
[[RoleTeamMapping]]
DN = 'cn=sre,ou=groups,dc=example,dc=org'
Roles = ['Standard']
Teams = [1]
[[RoleTeamMapping]]
DN = 'cn=platform-admins,ou=groups,dc=example,dc=org'
Roles = ['Admin']
Teams = [1, 2]
- 匹配的是用户条目的
memberOf属性,外加用户自己的 DN。 这个属性名是固定的,在[Attributes]里配别的名字不生效。 - 命中多条时,角色和团队取并集。
Teams填的是团队 ID,在 人员组织 → 团队管理 里选中团队后,右侧顶部那一行能看到。- 一条都没命中时,退回
DefaultRoles和DefaultTeams。 - 想让每次登录都按映射刷新角色,
CoverRoles和CoverAttributes必须同时为true。CoverRoles默认是false,也就是默认只在建账号那一次用映射, 之后在夜莺里手工改的角色不会被覆盖回去。这是这块最容易配错的一处。
预期结果:用一个属于 cn=sre 的账号登录,去 人员组织 → 用户管理 看这一行——
账号来源是 ldap,角色和团队跟映射一致。
5. 钉钉和飞书扫码
这两个是表单,不是 TOML。必填项:启用、显示名称、APP ID / Client ID、
APP Secret / Client secret、用户名字段、默认角色。回调地址分别是
https://<你的夜莺地址>/callback/dingtalk 和 .../callback/feishu,
要和开放平台那边登记的一致。
- 用户名字段决定拿对端的哪个属性当夜莺登录名。飞书可选 用户ID / 邮箱 / 手机号 / 名称, 钉钉可选 手机号 / 名称 / 邮箱。登录名建完就改不了,所以先想清楚: 选手机号或邮箱的风险是它们真的会变,选用户 ID 最稳但不好认人。
- 更新用户信息打开后,每次登录用对端的手机号和邮箱覆盖夜莺里的值。
- 钉钉的高级设置里还有
Endpoint、代理地址、以及用户详情开关—— 要从通讯录里取邮箱和手机号才需要打开它,并且要在钉钉开放平台上额外开通 「通讯录用户详情」权限,否则拿不到。 - 默认团队 / 默认角色在第一次登录建账号时生效。
6. 第一次登录建出来的账号长什么样
不管走哪个身份源,第一次登录都会自动建一个用户:
| 字段 | 来自 |
|---|---|
| 登录名 | [Attributes] Username,或表单里的「用户名字段」 |
| 显示名 / 手机 / 邮箱 | [Attributes] 里对应的那几项 |
| 角色 | DefaultRoles(LDAP 命中 [[RoleTeamMapping]] 时用映射的) |
| 团队 | DefaultTeams / 默认团队,没配就是空 |
在 人员组织 → 用户管理 里,这些账号的账号来源列显示身份源的名字, 一眼能分清哪些账号归你管、哪些归 IdP 管。
这里就是那个坑:默认角色给了 Standard,人也确实能登录进来,但如果团队是空的,
他打开界面还是什么都看不到——权限是两层的,第二层没接上。所以接 SSO 时
DefaultTeams(或表单里的默认团队)几乎总该配上,并且那个团队要在某个业务组里有授权。
展开见用户与团队和业务组授权。
还有一条:别在夜莺这边手工删 SSO 账号,下次登录又会被建回来。
账号的生命周期交给身份源,夜莺这边只做 SyncDelUsers 之类的跟随。
7. 反向代理认证是另一条路
如果前面已经有一层做统一认证的网关,可以不用这页的任何一个身份源,
改用配置文件里的 [HTTP.ProxyAuth]:网关认完把用户名放在请求头里传进来。
它和这六个不是一回事,也不能混用——开了 ProxyAuth,JWT 登录整体停用,
这页配出来的六个登录按钮也就都没用了。它的前提条件(17000 必须只对网关开放)
和风险见网络与 TLS 加固。