一、概念介绍
1.1 LDAP
LDAP(轻型目录访问协议)是一种用于访问和维护分布式目录服务的开放标准协议。LDAP最初是从X.500标准中派生出来的,但相比于X.500,LDAP更加简化和灵活。LDAP协议定义了客户端和服务器之间进行通信的规范,提供了一种在网络上访问和管理分布式目录数据的方式。
LDAP主要有以下几个概念
- 目录服务:目录服务是一种存储和组织数据的系统,类似于数据库,但它更适合于存储和检索大量的层次结构数据。目录服务通常用于存储组织结构信息、用户身份验证和授权等数据。
- 目录项:目录项是LDAP中存储的基本单位,每个目录项具有一个唯一的标识符(通常是一个全局唯一的Distinguished Name),并包含一组属性-值对。
- Distinguished Name(DN):Distinguished Name是一个目录项的完整路径标识符,由多个RDN(Relative Distinguished Name)组成,每个RDN由属性-值对构成。DN用于在目录中唯一标识一个目录项。
- 属性:LDAP目录项可以包含多个属性,每个属性由属性名和对应的值构成。属性名通常是标准化的OID(Object Identifier),比如"cn"表示通用名称、"sn"表示姓氏。
- 基准线(Base DN):基准线是LDAP服务器上搜索和操作目录项的起始位置,它指定了在哪个部分的目录层次结构下进行操作。
- 绑定(Binding):绑定是指客户端与LDAP服务器建立连接并验证身份的过程,以便进行后续操作。客户端需要提供有效的用户名和密码来进行绑定。
- 过滤器(Filter):过滤器用于对目录中的条目进行筛选和搜索,以满足特定的查询条件。常见的过滤器操作符包括等于、大于、小于、与、或、非等。
LDAP在企业环境中广泛应用,特别用于管理用户、组织结构和资源访问控制等信息。通过LDAP,可以实现统一的身份认证和授权管理,提供了集中化的目录服务,方便用户和应用程序访问和检索目录数据。
1.2 AD
1.2.1 AD定义
AD是Active Directory的缩写,AD是LDAP的一个应用实例,而不应该是LDAP本身。比如:windows域控的用户、权限管理应该是微软公司使用LDAP存储了一些数据来解决域控这个具体问题,只是AD顺便还提供了用户接口,也可以利用ActiveDirectory当做LDAP服务器存放一些自己的东西而已。比如LDAP是关系型数据库,微软自己在库中建立了几个表,每个表都定义好了字段。显然这些表和字段都是根据微软自己的需求定制的,而不是LDAP协议的规定。然后微软将LDAP做了一些封装接口,用户可以利用这些接口写程序操作LDAP,使得ActiveDirectory也成了一个LDAP服务器。
1.2.2 特点
- 组织架构管理:AD以层次结构的方式组织和管理目录数据。AD中的顶层单位是域(Domain),每个域都可以包含多个组织单位(OU)。这种层次结构可以方便地对资源和用户进行组织、分类和授权。
- 用户身份验证和授权:AD用于存储和验证用户的身份信息,并提供了灵活的权限控制机制。通过AD,可以实现对用户访问资源的授权、密码策略的管理以及单点登录等功能。
- 安全性和访问控制:AD提供了细粒度的访问控制功能,可以根据用户、组、组织单位等进行权限控制。管理员可以定义不同的安全策略和访问权限,以保护敏感数据和资源。
- 集中化管理:AD提供了集中化管理工具,如Active Directory Users and Computers(ADUC),使管理员可以方便地管理用户、组、计算机、策略等。通过这些工具,管理员可以轻松地创建、修改和删除目录对象。
- 多域和信任关系:AD支持多域环境,不同的域可以建立信任关系,实现域之间的资源共享和访问。这样,用户可以从一个域访问其他域中的资源,实现跨域认证和授权。
- 扩展性和复制:AD具有高度的可扩展性,可以根据需要添加或删除域控制器来适应组织的发展和变化。AD使用复制机制来保持域控制器之间的数据一致性,确保在整个网络中的目录数据是同步的。
总而言之,AD是一种强大的目录服务,广泛用于企业网络环境中,提供统一身份认证、访问控制和资源管理功能,并通过LDAP协议与客户端进行交互。它简化了用户和资源管理的过程,提供了高级的安全性和可扩展性,是企业网络的核心基础设施之一。
1.2.3 应用
- 用户服务:管理用户的域账号、用户信息、企业通信录(与电子邮箱系统集成)、用户组管理、用户身份认证、用户授权管理、按需实施组管理策略等。这里不单单指某些线上的应用更多的是指真实的计算机,服务器等
- 计算机管理:管理服务器及客户端计算机账户、所有服务器及客户端计算机加入域管理并按需实施组策略。
- 资源管理:管理打印机、文件共享服务、网络资源等实施组策略。
- 应用系统的支持:对于电子邮件(Exchange)、在线及时通讯(Lync)、企业信息管理(SharePoint)、微软CRM,ERP等业务系统提供数据认证(身份认证、数据集成、组织规则等)。这里不单是微软产品的集成,其它的业务系统根据公用接口的方式一样可以嵌入进来。
- 客户端桌面管理:系统管理员可以集中的配置各种桌面配置策略,如:用户适用域中资源权限限制、界面功能的限制、应用程序执行特征的限制、网络连接限制、安全配置限制等。
二、集成
1. 引入maven依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-ldap</artifactId>
</dependency>
2. 配置连接
#数据源配置
spring:
# ldap 连接信息
ldap:
urls:
- ldap://192.168.1.1:3268
#用于配置连接LDAP服务器时的用户名,通常是用于管理员权限的用户名。
username: cn=???,cn=Users,dc=???,dc=com
#用于配置连接LDAP服务器时的密码,与用户名相对应。
password: 123456
# #配置LDAP搜索的基础目录,所有的搜索操作都将在此基础目录下进行
base: dc=???,dc=com
3. 配置类
@Configuration
public class LdapConfig {
@Bean
@ConditionalOnMissingBean(LdapOperations.class)
public LdapTemplate ldapTemplate(ContextSource contextSource){
LdapTemplate ldapTemplate = new LdapTemplate(contextSource);
ldapTemplate.setIgnorePartialResultException(true);
return ldapTemplate;
}
}
4. 配置LdapPersonAttributeMapper
用于查询结果转换实体类
public class LdapPersonAttributeMapper implements AttributesMapper<Person> {
@Override
public Person mapFromAttributes(Attributes attributes) throws NamingException, javax.naming.NamingException {
Person person = new Person();
person.setName(attributes.get("cn").get().toString());
person.setAccount(attributes.get("sAMAccountName").get().toString());
person.setEmail(attributes.get("mail").get().toString());
person.setStatus(attributes.get("userAccountControl").get().toString());
person.setDn(LdapUtils.newLdapName(attributes.get("distinguishedName").get().toString()));
return person;
}
}
用户实体类
@Data
@Entry(objectClasses = "person")
public class Person {
@Id
@JsonSerialize(using = ToStringSerializer.class)
private Name dn;
@Attribute(name = "cn")
private String name;
@Attribute(name = "sAMAccountName")
private String account;
@Attribute(name = "mail")
private String email;
@Attribute(name = "userAccountControl")
protected String status;
@Attribute(name = "givenName")
private String givenName;
@Attribute(name = "sn")
private String lastName;
@Attribute(name = "telephoneNumber")
private String telephoneNumber;
@Attribute(name = "userPrincipalName")
private String userPrincipalName;
}
5. ldap操作实现类
@Slf4j
@Service
@AllArgsConstructor
public class LdapService {
private final LdapTemplate ldapTemplate;
public final static String USER_LDAP_NOT_AUTH_PASS = "LDAP认证失败,请联系管理员";
public final static String USER_LDAP_NOT_VALUE = "登录账号或密码不能为空";
public final static int ACCOUNT_DISABLE = 0x0001 << 1; // 账户已禁用
public final static int LOCKOUT = 0x0001 << 4; // 账户已锁定
public final static int PASSWD_NOTREQD = 0x0001 << 5; // 不需要密码
public final static int PASSWD_CANT_CHANGE = 0x0001 << 6; // 用户不能更改密码(只读,不能修改)
public final static int NORMAL_ACCOUNT = 0x0001 << 9; // 正常账户
public final static int DONT_EXPIRE_PASSWORD = 0x0001 << 16; // 密码永不过期
public final static int PASSWORD_EXPIRED = 0x0001 << 23; // 密码已过期
/**
* 账号认证
* LDAP认证过程中出现的错误代码
* 525 用户不存在
* 52e 密码或凭证无效
* 530 此时不允许登录
* 531 在此工作站上不允许登录
* 532 密码过期
* 533 账户禁用
* 701 账户过期
* 773 用户必须重置密码
* 775 用户账户锁定
*/
public R auth(String account, String password) {
if (Func.isBlank(account) || Func.isBlank(password)){
throw new ServiceException(USER_LDAP_NOT_VALUE);
}
try {
ldapTemplate.authenticate(LdapQueryBuilder.query().where("sAMAccountName").is(account), password);
} catch (Exception e) {
String message = e.getMessage();
log.error(message);
for (LdapAuthCodeEnum ldapAuthCodeEnum : LdapAuthCodeEnum.values()) {
if (message.contains(ldapAuthCodeEnum.code)) {
return R.fail(ldapAuthCodeEnum.message);
}
}
return R.fail(USER_LDAP_NOT_AUTH_PASS);
}
return R.status(Boolean.TRUE);
}
/**
* 账号查找
*/
public Person find1(String account) {
Person person = ldapTemplate.findOne(LdapQueryBuilder.query().where("sAMAccountName").is(account)
.and("objectClass").is("person"), Person.class);
return person;
}
/**
* 账号查找
*/
public Person find2(String account) {
List<Person> list = ldapTemplate.find(LdapQueryBuilder.query()
.where("sAMAccountName").is(account)
.and("objectClass").is("person"), Person.class);
if (list == null || list.size() == 0) {
return null;
} else if (list.size() > 1) {
throw new RuntimeException("匹配到多个用户");
} else {
return list.get(0);
}
}
/**
* 查询所有用户
*/
public List<Person> queryAll1(String ou) {
List<Person> list = ldapTemplate.find(LdapQueryBuilder.query().base("ou="+ou).where("objectClass").is("person"), Person.class);
return list;
}
/**
* 查找用户
*/
public List<Person> queryAll2() {
List<Person> list = ldapTemplate.search(LdapQueryBuilder.query().base("ou=测试集团").where("objectClass").is("person"), new LdapPersonAttributeMapper());
return list;
}
/**
* 分页查找用户
*/
public List<Person> queryAllPage() {
SearchControls controls = new SearchControls();
controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
controls.setReturningObjFlag(true);
SearchExecutor executor = ctx -> ctx.search(LdapUtils.newLdapName("ou=测试集团"), "(&(objectClass=person))", controls);
PagedResultsCookie cookie = null;
PagedResultsDirContextProcessor requestControl;
AttributesMapperCallbackHandler<Person> callbackHandler = new AttributesMapperCallbackHandler<>(new LdapPersonAttributeMapper());
do {
requestControl = new PagedResultsDirContextProcessor(500, cookie);
ldapTemplate.search(executor, callbackHandler, requestControl);
cookie = requestControl.getCookie();
} while (requestControl.hasMore());
return callbackHandler.getList();
}
/**
* 增量查询
*/
public List<Person> queryDiff1(Date startTime) {
SimpleDateFormat format = new SimpleDateFormat("yyyyMMddHHmmss.SZ");
List<Person> list = ldapTemplate.find(LdapQueryBuilder.query().base("ou=测试集团").where("objectClass").is("person")
.and("whenChanged").gte(format.format(startTime)), Person.class);
return list;
}
/**
* 增量查询
*/
public List<Person> queryDiff2(Date startTime) {
SimpleDateFormat format = new SimpleDateFormat("yyyyMMddHHmmss.SZ");
List<Person> list = ldapTemplate.search(LdapQueryBuilder.query().base("ou=测试集团").where("objectClass").is("person")
.and("whenChanged").gte(format.format(startTime)), new LdapPersonAttributeMapper());
return list;
}
/**
* 添加ou
*/
public boolean addOU(String name, String baseDn) {
Attributes ouAttributes = new BasicAttributes();
BasicAttribute ouBasicAttribute = new BasicAttribute("objectClass");
ouBasicAttribute.add("organizationalUnit");
ouAttributes.put(ouBasicAttribute);
if (!StringUtils.hasText(baseDn)) {
baseDn = "";
}
LdapName dn = LdapUtils.newLdapName(baseDn);
try {
dn.add(new Rdn("ou", name));
} catch (InvalidNameException e) {
log.error("{}", e.getMessage(), e);
return false;
}
ldapTemplate.bind(dn, null, ouAttributes);
return true;
}
/**
* 删除ou
*/
public boolean deleteOU(String dn, boolean recursive) {
try {
ldapTemplate.lookup(dn);
} catch (NameNotFoundException e) {
throw new RuntimeException("OU不存在");
}
ldapTemplate.unbind(dn, recursive);
return true;
}
/**
* 添加用户
*/
public boolean addUser(String name, String account, String baseDn) {
Attributes attrs = new BasicAttributes();
BasicAttribute oc = new BasicAttribute("objectClass");
oc.add("top");
oc.add("person");
oc.add("organizationalPerson");
oc.add("user");
attrs.put(oc);
attrs.put("sAMAccountName", account);
attrs.put("userPrincipalName", account+"@qq.com");
attrs.put("cn", name);
attrs.put("displayName", name);
attrs.put("mail", account + "@qq.com");
int status = NORMAL_ACCOUNT;
attrs.put("userAccountControl", String.valueOf(status));
String pwd = ""; // 初始密码
attrs.put("unicodePwd", encodePwd(pwd));
if (!StringUtils.hasText(baseDn)) {
baseDn = "";
}
LdapName dn = LdapNameBuilder.newInstance(baseDn)
.add("cn", name)
.build();
ldapTemplate.bind(dn, null, attrs);
return true;
}
/**
* 删除用户
*/
public boolean deleteUser(String dn) {
try {
ldapTemplate.lookup(dn);
} catch (NameNotFoundException e) {
throw new RuntimeException("用户不存在");
}
ldapTemplate.unbind(dn);
return true;
}
/**
* 修改密码
*/
public boolean updatePwd(String dn, String newPwd) {
try {
ldapTemplate.lookup(dn);
} catch (NameNotFoundException e) {
throw new RuntimeException("用户不存在");
}
ModificationItem[] mods = new ModificationItem[1];
mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, new BasicAttribute("unicodePwd", encodePwd(newPwd)));
ldapTemplate.modifyAttributes(dn, mods);
return true;
}
/**
* 添加用户组
*/
public boolean addGroup(String name, String baseDn) {
Attributes groupAttributes=new BasicAttributes();
BasicAttribute oc=new BasicAttribute("objectClass");
oc.add("top");
oc.add("group");
groupAttributes.put(oc);
groupAttributes.put("cn", name);
groupAttributes.put("sAMAccountName", name);
BasicAttribute member = new BasicAttribute("member");
member.add("cn=张三,ou=部门一,ou=测试集团");
member.add("cn=李四,ou=部门一,ou=测试集团");
groupAttributes.put(member);
if(!StringUtils.hasText(baseDn)){
baseDn = "";
}
LdapName dn = LdapUtils.newLdapName(baseDn);
try {
dn.add(new Rdn("cn", name));
} catch (InvalidNameException e) {
log.error("{}",e.getMessage(), e);
return false;
}
ldapTemplate.bind(dn,null,groupAttributes);
return true;
}
/**
* 添加用户至用户组
*/
public boolean addUserToGroup(String userDn, String groupDn) {
DirContextOperations ctxGroup;
try {
ctxGroup = ldapTemplate.lookupContext(groupDn);
}catch (NameNotFoundException e){
throw new RuntimeException("用户组不存在");
}
DirContextOperations ctxUser;
try {
ctxUser = ldapTemplate.lookupContext(userDn);
}catch (NameNotFoundException e){
throw new RuntimeException("用户组不存在");
}
ctxGroup.addAttributeValue("member", ctxUser.getStringAttribute("distinguishedName"));
ldapTemplate.modifyAttributes(ctxGroup);
return true;
}
/**
* 从用户组中删除用户
*/
public boolean removeUserFromGroup(String userDn, String groupDn) {
DirContextOperations ctxGroup;
try {
ctxGroup = ldapTemplate.lookupContext(groupDn);
}catch (NameNotFoundException e){
throw new RuntimeException("用户组不存在");
}
DirContextOperations ctxUser;
try {
ctxUser = ldapTemplate.lookupContext(userDn);
}catch (NameNotFoundException e){
throw new RuntimeException("用户组不存在");
}
ctxGroup.removeAttributeValue("member", ctxUser.getStringAttribute("distinguishedname"));
ldapTemplate.modifyAttributes(ctxGroup);
return true;
}
private byte[] encodePwd(String source) {
String quotedPassword = "\"" + source + "\""; // 注意:必须在密码前后加上双引号
return quotedPassword.getBytes(StandardCharsets.UTF_16LE);
}
}
增加Ldap登录模式
注:ldap是整个企业的用户验证,ldap验证通过并不等于可以登录当前系统,需要管理员将指定人员加入当前系统,进行绑定。所以ldap认证通过,还要验证本系统的用户是否绑定域登录账号, 结合ldap的auth接口自己实现
四、常见问题
1. Ldap认证错误编码
/**
* Ldap认证错误编码 枚举类
*/
@Getter
@AllArgsConstructor
public enum LdapAuthCodeEnum {
CODE_525("525", "域用户不存在"),
CODE_52e("52e", "域密码或凭证无效"),
CODE_530("530", "域此时不允许登录"),
CODE_531("531", "在此工作站上不允许登录"),
CODE_532("532", "域密码过期"),
CODE_533("533", "域账户禁用"),
CODE_701("701", "域账户过期"),
CODE_773("773", "域用户必须重置密码"),
CODE_775("775", "域用户账户锁定");
String code;
String message;
}
// 80090308: LdapErr: DSID-0C09030B, comment: AcceptSecurityContext error, data 525 , v893
// 十六进制: 0x525 - 用户不存在
// 十进制: 1317 - ERROR_NO_SUCH_USER (指定的账户不存在.)
// 注释: 当用户名无效时返回
// 80090308: LdapErr: DSID-0C09030B, comment: AcceptSecurityContext error, data 52e, v893
// 十六进制: 0x52e - 无效的凭证
// 十进制: 1326 - ERROR_LOGON_FAILURE (登录失败,未知的用户名或者密码错误.)
// 注释: 当用户名有效但是密码或者凭证无效的时候返回。
// 80090308: LdapErr: DSID-0C09030B, comment: AcceptSecurityContext error, data 530, v893
// 十六进制: 0x530 - 此时禁止登录
// 十进制: 1328 - ERROR_INVALID_LOGON_HOURS (登录失败,登录时间违规.)
// 注释: 仅当输入了正确的用户名和密码或凭证时才返回此值。说明用户被禁止登录了
// 80090308: LdapErr: DSID-0C09030B, comment: AcceptSecurityContext error, data 531, v893
// 十六进制: 0x531 - 此用户禁止在当前工作站登录
// 十进制: 1329 - ERROR_INVALID_WORKSTATION (登录失败,在此计算机上该用户不允许登录.)
// LDAP[userWorkstations: <multivalued list of workstation names>]
// 注释: 当输入了正确的用户名和密码或凭证时才返回此值。
// 80090308: LdapErr: DSID-0C09030B, comment: AcceptSecurityContext error, data 532, v893
// 十六进制: 0x532 - 密码过期
// 十进制: 1330 - ERROR_PASSWORD_EXPIRED (登录失败,指定的账户密码过期.)
// LDAP[userAccountControl: <bitmask=0x00800000>] - PASSWORDEXPIRED
// 注释: 当输入了正确的用户名和密码或凭证是猜返回此值。
// 80090308: LdapErr: DSID-0C09030B, comment: AcceptSecurityContext error, data 533, v893
// 十六进制: 0x533 - 账户被禁用
// 十进制: 1331 - ERROR_ACCOUNT_DISABLED (登录失败,账户当前被禁用了.)
// LDAP[userAccountControl: <bitmask=0x00000002>] - ACCOUNTDISABLE
// 注释: 当输入了正确的用户名和密码或凭证是猜返回此值。
// 80090308: LdapErr: DSID-0C09030B, comment: AcceptSecurityContext error, data 701, v893
// 十六进制: 0x701 - 账户已过期
// 十进制: 1793 - ERROR_ACCOUNT_EXPIRED (用户账户已过期.)
// LDAP[accountExpires: <value of -1, 0, or extemely large value indicates account will not expire>] - ACCOUNTEXPIRED
// 注释: 当输入了正确的用户名和密码或凭证是猜返回此值。
// 80090308: LdapErr: DSID-0C09030B, comment: AcceptSecurityContext error, data 773, v893
// 十六进制: 0x773 - 账户密码必须被重置
// 十进制: 1907 - ERROR_PASSWORD_MUST_CHANGE (用户密码在第一次登录之前必须修改.)
// LDAP[pwdLastSet: <value of 0 indicates admin-required password change>] - MUST_CHANGE_PASSWD
// 注释: 当输入了正确的用户名和密码或凭证是猜返回此值。
// 80090308: LdapErr: DSID-0C09030B, comment: AcceptSecurityContext error, data 775, v893
// 十六进制: 0x775 - 账户被锁定
// 十进制: 1909 - ERROR_ACCOUNT_LOCKED_OUT (账户当前已被锁定,不允许登录The referenced account is currently locked out and may not be logged on to.)
// LDAP[userAccountControl: <bitmask=0x00000010>] - LOCKOUT
// 注释: 即便是输入了错误的密码也可能返回此值
2. LDAP认证信息配置
注:LDAP 389端口和3268的区别
LDAP(Lightweight Directory Access Protocol)通常使用两个不同的端口进行通信,分别是389和3268。它们之间的主要区别在于目标服务器和所执行的操作。
- LDAP端口 389:
用途: 389端口是LDAP的标准端口,主要用于非安全的LDAP通信。
目标服务器: 通常用于与单个LDAP服务器建立连接,执行标准的LDAP操作。
操作类型: 适用于基本的LDAP查询、添加、修改、删除等操作。
加密: 数据在传输过程中不加密,可能存在安全风险。
- LDAP端口 3268:
用途: 3268端口也是LDAP端口,但用于LDAP全局编录服务。
目标服务器: 用于在整个Active Directory森林中搜索(查询)用户信息。
操作类型: 主要用于执行LDAP搜索操作,支持在多个域控制器之间进行全局搜索。
加密: 支持加密通信,提供更安全的数据传输。
总结:
使用389端口时,连接的是特定的LDAP服务器,适用于单个域或单个目录树。
使用3268端口时,连接的是全局编录服务,适用于在整个Active Directory森林中进行全局搜索,跨越多个域。