背景

使用 Atlassian 全家桶比如 Confluence、JIRA 的话,常用它家自带的单点登录方案 Atlassian Crowd,但 Crowd 的认证协议不是标准的,OpenID Connect 支持SAML 支持需要 Data Center license,比较贵(其实只是作为 OIDC/SAML client,并不是作为 provider)。Redhat 家的 Keycloak 支持标准的 SAML 和 OpenID Connect 认证协议,而且 Crowd 和 Keycloak 都支持以 LDAP 目录服务来存储用户、用户组信息,这样我们可以把用户、组迁移到 LDAP 服务里,Crowd 和 Keycloak 并行运行逐步过渡,而 LDAP 认证也被许多 Unix/Linux 传统服务支持,整个方案完美!

但是一丢丢不足的是,Crowd 和 Keycloak 都支持禁用用户,这个禁用状态保存在 Crowd 和 Keycloak 自己的数据库里,这时直接通过 LDAP 做绑定认证依然是可以认证成功的,于是研究了下如何在 LDAP 里禁用用户,然后发现居然没有一个所有 LDAP 服务、客户端都遵守的标准做法!

方案

(1) 在 Active Directory 的 schema 里,User 类 的 UserAccountControl 属性是位域类型(bit fields),可以表达用户是否被禁止。

(2) 在 Active Directory Lightweight Directory Services(AD LDS) 的 schema 里,msDS-BindableObject 类的 msDS-UserAccountDisabled 属性是布尔类型,可以表达用户是否被禁止。

(3) libnss-ldaplibnss-ldapd 在 LDAP 里使用 ShadowAccount 类保存 /etc/shadow 里的信息,这个类的 shadowExpire 属性是日期类型,可以表达用户是否被禁止。usermod --expiredate 命令就是修改这个属性。ActiveDirectory 不支持。

(4) 使用 pwdPolicy 类来禁用用户。

LDAP server 对四种方案的支持情况:上面前两种是微软家专用(Samba 支持第一种),第三种微软家不支持,第四种大家都支持。

Crowd 和 Keycloak 支持前两种,其中 Crowd 在使用 AD LDS 时不能打开增量同步。 由于 Crowd 和 Keycloak 只是用了 AD 的纯 LDAP 部分功能,所以我们可以用其它 LDAP 服务器比如 Apache Directory Server 来假冒 AD:创建一个 schema,包含上面用到的 AD 属性(其实还有几个其它属性)以及对应的 object class,让 LDAP 里的用户信息额外使用这个 object class,于是就具备了所需的属性了。

操作

于是开始令人窒息的「假冒伪劣」操作,Keycloak 开源源码,很容易找到对应的属性,Crowd 理论上也能反编译其 jar 包找到使用了哪些属性。

https://github.com/keycloak/keycloak/blob/12.0.1/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapper.java

https://github.com/keycloak/keycloak/blob/12.0.1/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/msadlds/MSADLDSUserAccountControlStorageMapper.java

从源码里可以很容易发现除了 UserAccountControl 和 msDS-UserAccountDisabled 属性外,还必需 pwdLastSet 属性。根据微软的文档,约莫可以猜出这些 attribute type 和 object class 的定义。 这里多解释一下 LDAP 的 schema,在 LDAP schema 里最重要的成员就是 object class 和 attribute type,相当于 SQL table 和 SQL column,不同的是 LDAP schema 里 attribute type 是单独定义的,可以被多个 object class 共享,LDAP 里的目录节点(相当于 SQL row)可以使用多个 object class,而且 attribute type 之间、object class 之间支持继承,这些设计比 SQL 强大的多,但也注定了曲高和寡。跟 SQL column 有 data type 类似,LDAP attribute type 的类型称为 syntax,有整数、字符串等。

Attribute type 还有一些其它重要属性:

  • Usage,包含 User Applications 和 Directory Operation,前者指正常属性,后者指此属性是目录服务器内部维护的状态。
  • Single-value,表示此属性是否允许多个值。
  • Matching rules,有点类似 SQL 里的 collation,表示比较、排序规则。

object class 有个重要属性:

  • Class type: Abstract - 抽象类, Structural - 具体类, Auxiliary - 辅助类,类似编程语言里的 mixin。如果给 LDAP 用的 user 使用了两个没有继承关系的 Structural object class,那么 Keycloak 在往 LDAP 里创建 user 节点时会报错 “contains more than one STRUCTURAL ObjectClass”,此时可以使用辅助类,或者继承用到的最新子类,比如 inetOrgPerson。
  • Superior Classes: 指定父类。

然后开始正事了,首先下载一份 Apache DS 2.0,使用 bin/apacheds.sh start 启动,在下载一份 Apache Directory Studio,启动后创建 connection,认证的用户是 “uid=admin,ou=system”,默认密码是 “secret”。注意在查看 LDAP 中存储的节点数据时,有时候需要右键菜单里选择 “Reload Entry” 才会展开,比如在查看 “ou=schema” 下的 schema 定义时。

然后在 Apache Directory Studio 里通过菜单 Window -> Open Perspective -> Schema Editor (也可以点击工具栏最右边按钮) 切换到 Schema Editor 视图,在这个视图下新建 schema project,选择类型 “Online schema from a Directory Server”,然后新建 schema,然后在这个 schema 里新建 attribute type,再新建 object class 使用这些 attribute type,完成后选择这个 schema 导出成 LDIF:

# SCHEMA "MSADLDS-SCHEMA"
dn: cn=msadlds-schema, ou=schema
objectclass: metaSchema
objectclass: top
cn: msadlds-schema
m-dependencies: inetorgperson

dn: ou=attributetypes, cn=msadlds-schema, ou=schema
objectclass: organizationalUnit
objectclass: top
ou: attributetypes

dn: m-oid=1.2.840.113556.1.4.1853, ou=attributetypes, cn=msadlds-schema, ou=sche
 ma
objectclass: metaAttributeType
objectclass: metaTop
objectclass: top
m-oid: 1.2.840.113556.1.4.1853
m-name: msDS-UserAccountDisabled
m-name: ms-DS-User-Account-Disabled
m-description: https://docs.microsoft.com/en-us/windows/win32/adschema/a-msds-us
 eraccountdisabled
m-equality: booleanMatch
m-syntax: 1.3.6.1.4.1.1466.115.121.1.7
m-singleValue: TRUE

dn: m-oid=1.2.840.113556.1.4.96, ou=attributetypes, cn=msadlds-schema, ou=schema
objectclass: metaAttributeType
objectclass: metaTop
objectclass: top
m-oid: 1.2.840.113556.1.4.96
m-name: pwdLastSet
m-name: Pwd-Last-Set
m-description: https://docs.microsoft.com/en-us/windows/win32/adschema/a-pwdlast
 set
m-equality: integerMatch
m-ordering: integerOrderingMatch
m-syntax: 1.3.6.1.4.1.1466.115.121.1.27
m-singleValue: TRUE

dn: m-oid=1.2.840.113556.1.4.8, ou=attributetypes, cn=msadlds-schema, ou=schema
objectclass: metaAttributeType
objectclass: metaTop
objectclass: top
m-oid: 1.2.840.113556.1.4.8
m-name: userAccountControl
m-name: User-Account-Control
m-description: https://docs.microsoft.com/en-us/windows/win32/adschema/a-useracc
 ountcontrol
m-equality: integerMatch
m-ordering: integerOrderingMatch
m-syntax: 1.3.6.1.4.1.1466.115.121.1.27
m-singleValue: TRUE

dn: ou=comparators, cn=msadlds-schema, ou=schema
objectclass: organizationalUnit
objectclass: top
ou: comparators

dn: ou=ditcontentrules, cn=msadlds-schema, ou=schema
objectclass: organizationalUnit
objectclass: top
ou: ditcontentrules

dn: ou=ditstructurerules, cn=msadlds-schema, ou=schema
objectclass: organizationalUnit
objectclass: top
ou: ditstructurerules

dn: ou=matchingrules, cn=msadlds-schema, ou=schema
objectclass: organizationalUnit
objectclass: top
ou: matchingrules

dn: ou=matchingruleuse, cn=msadlds-schema, ou=schema
objectclass: organizationalUnit
objectclass: top
ou: matchingruleuse

dn: ou=nameforms, cn=msadlds-schema, ou=schema
objectclass: organizationalUnit
objectclass: top
ou: nameforms

dn: ou=normalizers, cn=msadlds-schema, ou=schema
objectclass: organizationalUnit
objectclass: top
ou: normalizers

dn: ou=objectclasses, cn=msadlds-schema, ou=schema
objectclass: organizationalUnit
objectclass: top
ou: objectClasses

dn: m-oid=1.2.840.113556.1.5.244, ou=objectclasses, cn=msadlds-schema, ou=schema
objectclass: metaObjectClass
objectclass: metaTop
objectclass: top
m-oid: 1.2.840.113556.1.5.244
m-name: msDS-BindableObject
m-name: ms-DS-Bindable-Object
m-description: https://docs.microsoft.com/en-us/windows/win32/adschema/c-msds-bi
 ndableobject
m-typeObjectClass: AUXILIARY
m-may: msDS-UserAccountDisabled
m-may: pwdLastSet

dn: m-oid=1.2.840.113556.1.5.9, ou=objectclasses, cn=msadlds-schema, ou=schema
objectclass: metaObjectClass
objectclass: metaTop
objectclass: top
m-oid: 1.2.840.113556.1.5.9
m-name: user
m-name: User
m-description: https://docs.microsoft.com/en-us/windows/win32/adschema/c-user
m-supObjectClass: inetOrgPerson
m-may: userAccountControl
m-may: pwdLastSet

dn: ou=syntaxcheckers, cn=msadlds-schema, ou=schema
objectclass: organizationalUnit
objectclass: top
ou: syntaxcheckers

dn: ou=syntaxes, cn=msadlds-schema, ou=schema
objectclass: organizationalUnit
objectclass: top
ou: syntaxes

通过 Apache Directory Studio 菜单 Window -> Open Perspective -> LDAP 切换会 LDAP 视图,在 ou=schema 处导入 LDIF。

接下来在 Keycloak 的 admin web console 里创建 LDAP 类型的 User Federation。

  1. 在 Settings 标签页的 LDAP Vendor 选择 “Other”,如果是假冒 AD 则在 User Object Classes 输入框添加 “,user”,如果是假冒 AD LDS 则添加 “,msDS-BindableObject”
  2. 在 Mappers 标签页,如果是假冒 AD 则创建 msad-user-acccount-control-mapper,如果是假冒 AD LDS 则创建 msad-lds-user-account-control-mapper。

由于 Apache DS 不支持写入属性 pwdLastSet: -1 时自动替换成当前时间,所以需要给 Keycloak-12.0.1 打个补丁,把判断 getPwdLastSet() > 0的地方去掉:

diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapper.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapper.java
index fbd5b52e6e..ed9411fbb7 100644
--- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapper.java
+++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/msad/MSADUserAccountControlStorageMapper.java
@@ -220 +220 @@ public class MSADUserAccountControlStorageMapper extends AbstractLDAPStorageMapp
-            if (getPwdLastSet() > 0) {
+            //if (getPwdLastSet() > 0) {
@@ -223,4 +223,4 @@ public class MSADUserAccountControlStorageMapper extends AbstractLDAPStorageMapp
-            } else {
-                // If new MSAD user is created and pwdLastSet is still 0, MSAD account is in disabled state. So read just from Keycloak DB. User is not able to login via MSAD anyway
-                return kcEnabled;
-            }
+            //} else {
+            //    // If new MSAD user is created and pwdLastSet is still 0, MSAD account is in disabled state. So read just from Keycloak DB. User is not able to login via MSAD anyway
+            //    return kcEnabled;
+            //}
@@ -234 +234 @@ public class MSADUserAccountControlStorageMapper extends AbstractLDAPStorageMapp
-            if (ldapProvider.getEditMode() == UserStorageProvider.EditMode.WRITABLE && getPwdLastSet() > 0) {
+            if (ldapProvider.getEditMode() == UserStorageProvider.EditMode.WRITABLE /*&& getPwdLastSet() > 0*/) {
diff --git a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/msadlds/MSADLDSUserAccountControlStorageMapper.java b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/msadlds/MSADLDSUserAccountControlStorageMapper.java
index 38c5b728f1..0f55c11935 100644
--- a/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/msadlds/MSADLDSUserAccountControlStorageMapper.java
+++ b/federation/ldap/src/main/java/org/keycloak/storage/ldap/mappers/msadlds/MSADLDSUserAccountControlStorageMapper.java
@@ -183 +183 @@ public class MSADLDSUserAccountControlStorageMapper extends AbstractLDAPStorageM
-            if (getPwdLastSet() > 0) {
+            //if (getPwdLastSet() > 0) {
@@ -186,4 +186,4 @@ public class MSADLDSUserAccountControlStorageMapper extends AbstractLDAPStorageM
-            } else {
-                // If new MSAD LDS user is created and pwdLastSet is still 0, MSAD account is in disabled state. So read just from Keycloak DB. User is not able to login via MSAD anyway
-                return kcEnabled;
-            }
+            //} else {
+            //    // If new MSAD LDS user is created and pwdLastSet is still 0, MSAD account is in disabled state. So read just from Keycloak DB. User is not able to login via MSAD anyway
+            //    return kcEnabled;
+            //}
@@ -197 +197 @@ public class MSADLDSUserAccountControlStorageMapper extends AbstractLDAPStorageM
-            if (ldapProvider.getEditMode() == UserStorageProvider.EditMode.WRITABLE && getPwdLastSet() > 0) {
+            if (ldapProvider.getEditMode() == UserStorageProvider.EditMode.WRITABLE /*&& getPwdLastSet() > 0*/) {

在 keycloak/federation/ldap 下执行 mvn clean package ,然后把 target/keycloak-ldap-federation-12.0.1.jar 替换掉官方下载的二进制包里的 keycloak-12.0.1/modules/system/layers/keycloak/org/keycloak/keycloak-ldap-federation/main/keycloak-ldap-federation-12.0.1.jar。

效果

这么 hack 一下的结果是:

  1. LDAP -> Keycloak 同步 enabled 标志:如果在 LDAP 里把 msDS-UserAccountDisabled 设置成 TRUE(假冒 AD LDS) 或者把 userAccountControl 设置成 userAccountControl | 2(可以简化设置成 2,假冒 AD),则 Keycloak 里此用户被禁用;如果设置成 FALSE 或者 userAccountControl & ~2(可以简化设置成 0),并且在 Keycloak 里也 enable 一下此用户(上面补丁里可以看到 Keycloak 判断了两个开关),则用户解禁。

  2. Keycloak -> LDAP 同步 enabled 标志:Keycloak 里解禁、禁用用户,会立刻反映到 LDAP 里。