LDAP实战之登录验证、用户查询、获取全量有效用户及邮件组

概述

LDAP是一种协议,Lightweight Directory Access Protocol,轻量级目录访问协议,用于提供目录信息查询服务,基于TCP/IP协议。

目录服务:是一种特殊的数据库系统,其专门针对读取,浏览和搜索操作进行特定的优化。目录一般用来包含描述性的,基于属性的信息并支持精细复杂的过滤能力。目录一般不支持通用数据库针对大量更新操作操作需要的复杂的事务管理或回卷策略。而目录服务的更新则一般都非常简单。这种目录可以存储包括个人信息、web链结、jpeg图像等各种信息。

简称:
o:organization(组织-公司)
ou:organization unit(组织单元-部门)
c:countryName(国家)
dc:domainComponent(域名)
sn:suer name(真实名称)
cn:common name(常用名称)

LDAP目录中的信息是是按照树型结构组织,具体信息存储在条目(entry)的数据结构中。条目相当于关系数据库中表的记录;条目是具有区别名DN (Distinguished Name)的属性(Attribute),DN是用来引用条目的,DN相当于关系数据库表中的关键字(Primary Key)。属性由类型(Type)和一个或多个值(Values)组成,相当于关系数据库中的字段(Field)由字段名和数据类型组成,只是为了方便检索的需要,LDAP中的Type可以有多个Value,而不是关系数据库中为降低数据的冗余性要求实现的各个域必须是不相关的。LDAP中条目的组织一般按照地理位置和组织关系进行组织。LDAP把数据存放在文件中,为提高效率可以使用基于索引的文件数据库,而不是关系数据库。类型的一个例子就是mail,其值将是一个电子邮件地址。
LDAP的信息是以树型结构存储的,在树根一般定义国家(c=CN)或域名(dc=com),在其下则往往定义一个或多个组织 (organization)(o=Acme)或组织单元(organizational units) (ou=People)。一个组织单元可能包含诸如所有雇员、大楼内的所有打印机等信息。此外,LDAP支持对条目能够和必须支持哪些属性进行控制,这是有一个特殊的称为对象类别(objectClass)的属性来实现的。该属性的值决定该条目必须遵循的一些规则,规定该条目能够及至少应该包含哪些属性。例如:inetorgPerson对象类需要支持sn(surname)和cn(common name)属性,但也可以包含可选的如邮件,电话号码等属性。

入门

在Spring大家族中,Spring LDAP也占有一席之位,并且是放在Security下面。熟悉IDEA的同学,应该对下面这个截图不陌生:
在这里插入图片描述
勾选Spring LDAP后,项目会自动添加如下依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-ldap</artifactId>
</dependency>

不难知道starter引入如下依赖:

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-ldap</artifactId>
</dependency>

隶属于Spring Data子项目,包含如下最底层依赖:

<dependency>
    <groupId>com.sun</groupId>
    <artifactId>ldapbp</artifactId>
    <version>1.0</version>
    <scope>provided</scope>
</dependency>

本地测试开发时,在application.properties中添加嵌入式LDAP服务器配置。

unboundid-ldapsdk的官方介绍:

The UnboundID LDAP SDK for Java is a fast, comprehensive, and easy-to-use Java API for communicating with LDAP directory servers and performing related tasks like reading and writing LDIF, encoding and decoding data using base64 and ASN.1 BER, and performing secure communication. This package contains the Standard Edition of the LDAP SDK, which is a complete, general-purpose library for communicating with LDAPv3 directory servers.

ldap-server.ldif配置文件用来存储LDAP服务端的基础数据。

unboundid-ldapsdk主要是为了使用嵌入式的LDAP服务端来进行测试操作,故scope设置为test。生产应用中,会连接真实的、独立部署的LDAP服务器,则不需要此项依赖。

实战

登录验证

在开发内网平台或应用时,且未接入cas登录系统时,本应用需要维护一套用户名密码数据表。用户名一般就是公司的域账户,密码就是电脑开机密码。开机密码不可能同步备份到本应用的数据表里面来。另外,IT安全要求,每3个月需要更新一次电脑开机密码。也不可能就应用本身开发一个注册入口,新用户需要注册方可登录。那怎么办?就用电脑开机域账户密码那一套来登录内网应用系统。怎么做?接入LDAP!

用户查询

LDAP服务器连接成功后,通过LdapTemplate可以查询用户是否存在。

获取全量有效的用户

基于上面的登录功能实现下,内网应用的用户皆为公司内部员工,而员工数据是一个动态变化的数据,经常有用户离职,或者转岗等情况。接入LDAP后,可以拿到最新数据,筛选失效的用户。

获取全量有效的邮件组

同样,接入LDAP后,可以拿到全量的邮件组信息;

代码

用户实体类:

@Data
public class Person {
    // 用户名
    private String name;
    // 登录名
    private String sAMAccountName;
    private String company;
    // 所属组列表
    private List<String> role;
    private Long id;
    private String avatar;
    private String email;
    private String phone;
    private Boolean enabled;
    private String password;
    private Timestamp createTime;
    private Date lastPasswordResetTime;
    private Set<Role> roles;
    private Dept dept;
}

工具类,当然写成一个Spring Bean @Component类也行,下面代码中也有注入Spring Data提供的LdapTemplate模板模式类。

如果使用Spring Boot的Auto Configuration,则需要引入如下配置:

spring:
  ldap:
    urls: ldap://10.20.30.40:389
    username: ldap@aba.local
    password: thisispassword
    base: ou=ADA,dc=aba,dc=local # 可不配置?

直接给出工具类方法:

@Slf4j
@Component
public class LdapUtil {
    private static final String PRINCIPAL = "aaa";

    private static final String CREDENTIALS = "@@@";

    /**
     * 未激活的邮件组
     */
    private static final Integer GROUP_INACTIVATED = 0;
    /**
     * 激活的邮件组
     */
    private static final Integer GROUP_ACTIVATED = 1;
    @Resource
    private LdapTemplate ldapTemplate;
    
	public static boolean checkDomain(String userName, String password, String domainIp, String domainPort) {
	    String url = "ldap://" + domainIp + ":" + domainPort;
	    Hashtable<String, String> env = new Hashtable<>();
	    env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
	    env.put(Context.SECURITY_AUTHENTICATION, "simple");
	    env.put(Context.PROVIDER_URL, url);
	    env.put(Context.SECURITY_PRINCIPAL, userName);
	    env.put(Context.SECURITY_CREDENTIALS, password);
	    try {
	        // 初始化上下文,无报错则说明登录成功
	        DirContext ctx = new InitialDirContext(env);
	        ctx.close();
	        return true;
	    } catch (javax.naming.AuthenticationException e) {
	        logger.warn("认证失败:" + e.getMessage());
	        return false;
	    } catch (Exception e) {
	        logger.warn("认证出错:" + e.getMessage());
	        return false;
	    }
	}

    public Person findByUsername(String username, String password) {
        String userDn = "ada\\" + username;
        // 使用用户名、密码验证域用户
        DirContext ctx = ldapTemplate.getContextSource().getContext(userDn, password);
        // 如果验证成功根据sAMAccountName属性查询用户名和用户所属的组
        Person person = ldapTemplate.search(query().where("objectclass").is("person").and("sAMAccountName").is(username), new AttributesMapper<Person>() {
            @Override
            public Person mapFromAttributes(Attributes attributes) throws NamingException {
                Person person = new Person();
                person.setName(attributes.get("cn").get().toString());
                person.setSAMAccountName(attributes.get("sAMAccountName").get().toString());
                String name = attributes.get("distinguishedname").get().toString();
                String[] results = name.split(",");
                for (String role : results) {
                    if (StringUtils.startsWithIgnoreCase(role.trim(), "OU=")) {
                        person.setCompany(role.trim().replace("OU=", ""));
                        break;
                    }
                }
                String memberOf = attributes.get("memberOf").toString().replace("memberOf: ", "");
                List<String> list = new ArrayList<>();
                String[] roles = memberOf.split(",");
                for (String role : roles) {
                    if (StringUtils.startsWithIgnoreCase(role.trim(), "CN=")) {
                        list.add(role.trim().replace("CN=", ""));
                    }
                }
                person.setRole(list);
                return person;
            }
        }).get(0);
        // 关闭ldap连接
        LdapUtils.closeContext(ctx);
        return person;
    }
    
    /**
     * 获取所有在职用户和有效邮件组的邮箱地址
     */
    public static List<String> getAllValidUserMailAndGroupMail() {
        List<String> totalMailList = new ArrayList<>();
        try {
            // 获取所有在职用户
            List<LdapUser> userList = LdapUtil.loadUsers();
            log.info("getAllValidUserMailAndGroupMail 普通账号数:" + userList.size());
            for (LdapUser item : userList) {
                String mail = item.getMail().trim();
                if (!StringUtils.isBlank(mail) && mail.indexOf("@") > 0) {
                    totalMailList.add(mail.substring(0, mail.indexOf("@")));
                }
                // fix: {"account":"West","employee_id":"033110","is_admin":false,"mail":"west@johnny.com","name":"phc033110@johnny.com","status":"544","valid":true}
                if (StringUtils.isNotBlank(item.getName()) && item.getName().indexOf("@") > 0 &&
                        !totalMailList.contains(item.getName().substring(0, item.getName().indexOf("@")))) {
                    totalMailList.add(item.getName().substring(0, item.getName().indexOf("@")));
                }
            }
            // 获取所有有效邮件组
            List<MailGroup> mailGroupList = LdapUtil.loadMailGroups();
            log.info("getAllValidUserMailAndGroupMail 有效邮箱群组数:" + mailGroupList.size());
            for (MailGroup item : mailGroupList) {
                String mail = item.getMail().trim();
                if (StringUtils.isNotBlank(mail) && mail.indexOf("@") > 0) {
                    totalMailList.add(mail.substring(0, mail.indexOf("@")));
                }
            }
            log.info("getAllValidUserMailAndGroupMail total:" + totalMailList.size());
            return totalMailList;
        } catch (Exception e) {
            log.error("getAllValidUsersAndMailGroups");
            return totalMailList;
        }
    }

    /**
     * 捞出有效的邮件组
     */
    private static List<MailGroup> loadMailGroups() throws NamingException, IOException {
        LdapContext ctx = initialLdapContext();
        ctx.setRequestControls(new Control[]{new PagedResultsControl(1000, Control.NONCRITICAL)});

        SearchControls searchCtls = new SearchControls();
        searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
        searchCtls.setReturningAttributes(new String[]{"displayName", "sAMAccountName", "cn", "distinguishedName", "mail"});

        List<MailGroup> mailGroups = new ArrayList<>();
        byte[] cookie;
        do {
            NamingEnumeration<SearchResult> answer = ctx.search(
                    "OU=AAA总公司,DC=corp,DC=bbb,DC=com",
                    "(&(objectClass=group))",
                    searchCtls);

            while (answer != null && answer.hasMoreElements()) {
                Attributes ats = answer.next().getAttributes();
                MailGroup mailGroup = new MailGroup();
                getAttribute(ats, "sAMAccountName").ifPresent(mailGroup::setAccount);
                getAttribute(ats, "displayName").ifPresent(mailGroup::setName);
                getAttribute(ats, "mail").ifPresent(mailGroup::setMail);
                mailGroup.setStatus(GROUP_ACTIVATED);

                boolean invalid = StringUtils.isBlank(mailGroup.getAccount())
                        || StringUtils.isBlank(mailGroup.getName())
                        || StringUtils.isBlank(mailGroup.getMail());
                if (invalid) {
                    continue;
                }
                mailGroups.add(mailGroup);
            }
            cookie = Optional.ofNullable(ctx.getResponseControls())
                    .map(arr -> arr[0])
                    .map(PagedResultsResponseControl.class::cast)
                    .map(PagedResultsResponseControl::getCookie)
                    .orElse(null);

            ctx.setRequestControls(new Control[]{new PagedResultsControl(100, cookie, Control.CRITICAL)});
        } while (cookie != null && cookie.length != 0);
        ctx.close();
        return mailGroups.stream().filter(MailGroup::isValid).collect(Collectors.toList());
    }

    /**
     * 捞出有效的用户
     */
    private static List<LdapUser> loadUsers() throws NamingException, IOException {
        LdapContext ctx = initialLdapContext();
        ctx.setRequestControls(new Control[]{new PagedResultsControl(1000, Control.NONCRITICAL)});

        SearchControls searchCtls = new SearchControls();
        searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
        // 注释此行,返回全量属性
        searchCtls.setReturningAttributes(new String[]{"displayName", "sAMAccountName", "cn", "distinguishedName", "mail", "employeeID", "userAccountControl"});
        List<LdapUser> allUsers = new ArrayList<>();
        byte[] cookie;
        do {
            NamingEnumeration<SearchResult> answer = ctx.search(
                    "OU=AAA总公司,DC=corp,DC=bbb,DC=com",
                    "(&(objectClass=top)(objectClass=user)(objectClass=person)(objectClass=organizationalPerson))",
                    searchCtls);
            while (answer != null && answer.hasMoreElements()) {
                Attributes ats = answer.next().getAttributes();
                LdapUser ldapUser = new LdapUser();
                getAttribute(ats, "sAMAccountName").ifPresent(ldapUser::setAccount);
                getAttribute(ats, "displayName").ifPresent(ldapUser::setName);
                getAttribute(ats, "employeeID").ifPresent(ldapUser::setEmployeeId);
                getAttribute(ats, "mail").ifPresent(ldapUser::setMail);
                getAttribute(ats, "userAccountControl").ifPresent(ldapUser::setStatus);
                ldapUser.setIsAdmin(false);
                boolean invalid = StringUtils.isBlank(ldapUser.getAccount()) ||
                        StringUtils.isBlank(ldapUser.getName()) ||
                        StringUtils.isBlank(ldapUser.getMail());
                if (invalid) {
                    continue;
                }
                allUsers.add(ldapUser);
            }

            cookie = Optional.ofNullable(ctx.getResponseControls())
                    .map(arr -> arr[0])
                    .map(PagedResultsResponseControl.class::cast)
                    .map(PagedResultsResponseControl::getCookie)
                    .orElse(null);
            ctx.setRequestControls(new Control[]{new PagedResultsControl(100, cookie, Control.CRITICAL)});
        } while (cookie != null && cookie.length != 0);
        ctx.close();
        return allUsers.stream().filter(LdapUser::isValid).collect(Collectors.toList());
    }

	/**
     * 初始化LDAP上下文环境context
     */
    private static LdapContext initialLdapContext() throws NamingException {
        Properties env = new Properties();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.SECURITY_AUTHENTICATION, "simple");
        env.put(Context.PROVIDER_URL, "LDAP://ldaprw.corp.johnny.com");
        env.put("com.sun.jndi.ldap.connect.timeout", "2000");
        env.put(Context.SECURITY_PRINCIPAL, "corp\\" + PRINCIPAL);
        env.put(Context.SECURITY_CREDENTIALS, CREDENTIALS);
        return new InitialLdapContext(env, null);
    }

    private static Optional<String> getAttribute(Attributes ats, String name) {
        return Optional.ofNullable(ats).map(attributes -> attributes.get(name)).map(attribute -> {
            try {
                return attribute.get();
            } catch (NamingException e) {
                throw new RuntimeException(e);
            }
        }).map(Object::toString);
    }
}

附:全量字段信息:

{objectcategory=objectCategory: CN=Person,CN=Schema,CN=Configuration,DC=corp,DC=ai,DC=com, whencreated=whenCreated: 20140715085458.0Z, badpwdcount=badPwdCount: 0, codepage=codePage: 0, employeeid=employeeID: 001092, msexchblockedsendershash=msExchBlockedSendersHash: s�#v, mail=mail: johnny@aaa.com, objectguid=objectGUID: �O�w�5�E�nj	�_��, ms-ds-consistencyguid=mS-DS-ConsistencyGuid: �O�w�5�E�nj	�_��, memberof=memberOf: CN=碳中和,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=敬业度调研员工,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=VPN全员,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=bb笔记本用户,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=Townhall自驾,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=上海PM,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=上海aaa,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=拍米粒小团圆,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=aaa全体员工,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=人员,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=市场,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=media@aaa.com,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=媒体采访,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=上海aaa,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=PR内部信息共享,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=PM全员20190430024742,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=PMC序列20191122054352,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=papalist,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=notebook-owner20191108033850,OU=通讯组,OU=保留账号,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=owncloud-group,OU=DFS,DC=corp,DC=aaa,DC=com, CN=音乐视频权限,OU=深信服权限组,OU=DFS,DC=corp,DC=aaa,DC=com, CN=逍遥派(公关中心)-媒介(share),OU=htd-share,OU=DFS,DC=corp,DC=aaa,DC=com, CN=sslvpn-common,CN=Users,DC=corp,DC=aaa,DC=com, CN=sslvpn-main,OU=GROUP,OU=aaa总公司,DC=corp,DC=aaa,DC=com, CN=wireless-users,OU=GROUP,OU=aaa总公司,DC=corp,DC=aaa,DC=com, msexchmailboxguid=msExchMailboxGuid: ^˝2��L�܈��, instancetype=instanceType: 0, internetencoding=internetEncoding: 1310720, msds-externaldirectoryobjectid=msDS-ExternalDirectoryObjectId: User_4398b697-10a0-4b80-bba5-467aa5ef502e, objectsid=objectSid: }

参考

posted @ 2022-05-31 16:54  johnny233  阅读(658)  评论(0编辑  收藏  举报  来源