菜单

LDAP应用对接示例代码

下载

Java示例代码

language 复制代码
      
/**
 * LDAP 客户端测试工具(基于 UnboundID LDAP SDK,单文件运行)
 * ==========================================================
 *
 * 用途:
 *     连接 AD / OpenLDAP 等 LDAP 服务器,测试连接与认证,
 *     并对 用户 / 组织(OU) / 角色(组) 进行增删改查。
 *
 * 依赖:
 *     只需一个 jar:unboundid-ldapsdk(纯 Java,无其他依赖)
 *     Maven 坐标:
 *         <dependency>
 *             <groupId>com.unboundid</groupId>
 *             <artifactId>unboundid-ldapsdk</artifactId>
 *             <version>6.0.3</version>
 *         </dependency>
 *
 * 编译与运行(把 jar 和本文件放在同一目录):
 *     javac -encoding UTF-8 -cp unboundid-ldapsdk-6.0.3.jar LdapClient.java
 *     java -cp .:unboundid-ldapsdk-6.0.3.jar LdapClient        # Linux / macOS
 *     java -cp .;unboundid-ldapsdk-6.0.3.jar LdapClient        # Windows(分隔符用分号)
 *
 * 使用步骤:
 *     1. 修改下方"配置区"的服务器地址、账号、BASE_DN 等参数(每行都有注释)
 *     2. 编译并运行,按中文菜单提示操作即可
 *
 * 注意:
 *     - AD 重置用户密码必须使用加密连接(USE_SSL=true 或 USE_STARTTLS=true),
 *       这是 AD 服务端的强制要求。
 *     - 本工具仅做内网测试用途,默认信任所有 TLS 证书(兼容自签名证书)。
 */

import com.unboundid.asn1.ASN1OctetString;
import com.unboundid.ldap.sdk.Attribute;
import com.unboundid.ldap.sdk.Control;
import com.unboundid.ldap.sdk.DereferencePolicy;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.Filter;
import com.unboundid.ldap.sdk.LDAPConnection;
import com.unboundid.ldap.sdk.LDAPConnectionOptions;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.Modification;
import com.unboundid.ldap.sdk.ModificationType;
import com.unboundid.ldap.sdk.RootDSE;
import com.unboundid.ldap.sdk.SearchRequest;
import com.unboundid.ldap.sdk.SearchResult;
import com.unboundid.ldap.sdk.SearchResultEntry;
import com.unboundid.ldap.sdk.SearchScope;
import com.unboundid.ldap.sdk.controls.SimplePagedResultsControl;
import com.unboundid.ldap.sdk.extensions.StartTLSExtendedRequest;
import com.unboundid.util.ssl.SSLUtil;
import com.unboundid.util.ssl.TrustAllTrustManager;

import java.io.BufferedReader;
import java.io.Console;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class LdapClient {

    // ======================================================================
    //                            配  置  区
    //            (只需要改这里,每行右侧都有中文说明)
    // ======================================================================

    static final String  LDAP_SERVER   = "192.168.1.10";      // LDAP 服务器地址(IP 或域名)
    static final int     LDAP_PORT     = 389;                 // 端口:389=明文或StartTLS,636=LDAPS加密
    static final boolean USE_SSL       = false;               // true=使用LDAPS(端口一般改成636);AD改密码必须加密
    static final boolean USE_STARTTLS  = false;               // true=先明文连接再升级为TLS(端口保持389);与 USE_SSL 二选一
    static final int     CONNECT_TIMEOUT_MS = 8000;           // 连接/操作超时时间(毫秒)

    static final String  BIND_DN       = "cn=admin,dc=example,dc=com"; // 绑定(登录)账号;AD 也可填 "域名\\账号" 或 "账号@域名";留空则匿名绑定
    static final String  BIND_PASSWORD = "";                  // 绑定账号密码;留空则运行时交互式输入(更安全)

    static final String  BASE_DN       = "dc=example,dc=com"; // 搜索根节点(目录的最顶层DN)

    // 服务器类型:微软 AD 填 "ad";OpenLDAP 填 "openldap"
    // (程序据此自动切换属性名、密码字段、启用禁用方式等差异)
    static final String  SERVER_TYPE   = "openldap";

    // 新建用户/角色默认存放的容器(不存在时请先通过"组织管理"菜单创建)
    static final String  USER_OU       = "ou=users,dc=example,dc=com";   // 新建用户的默认容器
    static final String  GROUP_OU      = "ou=groups,dc=example,dc=com";  // 新建角色(组)的默认容器

    static final int     SEARCH_PAGE_SIZE = 500;              // 分页查询每页条数(AD 单次查询上限1000,程序自动翻页)

    // ======================================================================
    //              业务操作层(屏蔽 AD 与 OpenLDAP 的属性差异)
    // ======================================================================

    // AD: 用户类 user / 登录名 sAMAccountName / 组 group
    // OpenLDAP: 用户类 inetOrgPerson / 登录名 uid / 组 groupOfNames
    static final boolean IS_AD = "ad".equalsIgnoreCase(SERVER_TYPE.trim());
    static final String LOGIN_ATTR = IS_AD ? "sAMAccountName" : "uid";
    static final String USER_OBJECTCLASS = IS_AD ? "user" : "inetOrgPerson";
    static final String[] USER_CLASSES = IS_AD
            ? new String[]{"top", "person", "organizationalPerson", "user"}
            : new String[]{"top", "person", "organizationalPerson", "inetOrgPerson"};
    static final String GROUP_OBJECTCLASS = IS_AD ? "group" : "groupOfNames";
    static final String[] GROUP_CLASSES = IS_AD
            ? new String[]{"top", "group"}
            : new String[]{"top", "groupOfNames"};
    static final String MEMBER_ATTR = "member";

    // AD 组类型:-2147483646 = 0x80000002 = 全局安全组(最常用的角色类型)
    static final String AD_GROUP_TYPE = "-2147483646";

    static LDAPConnection conn;          // 当前连接(全局单例)
    static String passwordCache;         // 交互输入的绑定密码缓存,避免反复输入
    static final BufferedReader IN = new BufferedReader(new InputStreamReader(System.in));

    // ---------------- 连接管理 ----------------

    /** 获取可用连接:未连接或已断开时自动重连并完成认证 */
    static LDAPConnection getConnection() throws Exception {
        if (conn != null && conn.isConnected()) {
            return conn;
        }
        closeConnection();

        LDAPConnectionOptions options = new LDAPConnectionOptions();
        options.setConnectTimeoutMillis(CONNECT_TIMEOUT_MS);
        options.setResponseTimeoutMillis(CONNECT_TIMEOUT_MS);

        // 内网测试:信任所有服务器证书,兼容自签名证书
        SSLUtil sslUtil = new SSLUtil(new TrustAllTrustManager());

        LDAPConnection c;
        if (USE_SSL) {
            c = new LDAPConnection(sslUtil.createSSLSocketFactory(), options, LDAP_SERVER, LDAP_PORT);
        } else {
            c = new LDAPConnection(options, LDAP_SERVER, LDAP_PORT);
        }
        if (USE_STARTTLS && !USE_SSL) {
            c.processExtendedOperation(new StartTLSExtendedRequest(sslUtil.createSSLSocketFactory()));
        }

        String pw = BIND_PASSWORD.isEmpty() ? passwordCache : BIND_PASSWORD;
        if (!BIND_DN.isEmpty() && pw == null) {
            pw = readPassword("请输入绑定账号 " + BIND_DN + " 的密码: ");
            passwordCache = pw;
        }
        c.bind(BIND_DN, pw == null ? "" : pw);   // BIND_DN 留空即匿名绑定
        conn = c;
        return c;
    }

    static void closeConnection() {
        if (conn != null) {
            conn.close();
            conn = null;
        }
    }

    static boolean isEncrypted() {
        return USE_SSL || USE_STARTTLS;
    }

    // ---------------- 搜索(自动分页) ----------------

    /** 执行搜索并自动翻页,返回全部匹配条目 */
    static List<SearchResultEntry> search(LDAPConnection c, String base, SearchScope scope,
                                          Filter filter, String... attrs) throws LDAPException {
        List<SearchResultEntry> entries = new ArrayList<>();
        ASN1OctetString cookie = null;
        do {
            Control pagedControl = (cookie == null)
                    ? new SimplePagedResultsControl(SEARCH_PAGE_SIZE)
                    : new SimplePagedResultsControl(SEARCH_PAGE_SIZE, cookie);
            SearchRequest request = new SearchRequest(null, new Control[]{pagedControl},
                    base, scope, DereferencePolicy.NEVER, 0, CONNECT_TIMEOUT_MS / 1000,
                    false, filter, attrs);
            SearchResult result = c.search(request);
            entries.addAll(result.getSearchEntries());
            SimplePagedResultsControl paged = SimplePagedResultsControl.get(result);
            cookie = (paged != null && paged.moreResultsToReturn()) ? paged.getCookie() : null;
        } while (cookie != null);
        return entries;
    }

    /** 搜索并返回第一个条目,无结果返回 null */
    static SearchResultEntry searchOne(LDAPConnection c, String base, SearchScope scope,
                                       Filter filter, String... attrs) throws LDAPException {
        List<SearchResultEntry> entries = search(c, base, scope, filter, attrs);
        return entries.isEmpty() ? null : entries.get(0);
    }

    // ---------------- 显示与选择辅助 ----------------

    static void showEntry(Entry entry, String title) {
        System.out.println("\n----- " + title + " -----");
        System.out.println("DN: " + entry.getDN());
        List<Attribute> attrs = new ArrayList<>(entry.getAttributes());
        attrs.sort((a, b) -> a.getName().compareToIgnoreCase(b.getName()));
        for (Attribute attr : attrs) {
            System.out.println("  " + attr.getName() + ": " + String.join(", ", attr.getValues()));
        }
        System.out.println("----------------------------------------");
    }

    /** 从搜索结果中让用户挑选一个,返回条目或 null */
    static Entry pickEntry(List<SearchResultEntry> entries, String what) throws Exception {
        if (entries.isEmpty()) {
            System.out.println("未找到匹配的" + what + "。");
            return null;
        }
        if (entries.size() == 1) {
            showEntry(entries.get(0), "条目详情");
            return "y".equalsIgnoreCase(prompt("确认操作该" + what + "?(y/n): ")) ? entries.get(0) : null;
        }
        System.out.println("\n找到 " + entries.size() + " 个匹配的" + what + ":");
        for (int i = 0; i < entries.size(); i++) {
            Entry e = entries.get(i);
            String name = e.getAttributeValue("cn") != null ? e.getAttributeValue("cn")
                    : e.getAttributeValue("ou") != null ? e.getAttributeValue("ou") : "";
            System.out.println("  " + (i + 1) + ". " + name + "    (" + e.getDN() + ")");
        }
        String raw = prompt("请输入序号选择(回车取消): ");
        try {
            int index = Integer.parseInt(raw);
            if (index >= 1 && index <= entries.size()) {
                return entries.get(index - 1);
            }
        } catch (NumberFormatException ignored) {
        }
        return null;
    }

    /** 按登录名/姓名/邮箱模糊搜索用户 */
    static List<SearchResultEntry> findUser(LDAPConnection c, String keyword) throws LDAPException {
        List<Filter> orClauses = new ArrayList<>();
        orClauses.add(Filter.createSubstringFilter(LOGIN_ATTR, null, new String[]{keyword}, null));
        orClauses.add(Filter.createSubstringFilter("cn", null, new String[]{keyword}, null));
        orClauses.add(Filter.createSubstringFilter("mail", null, new String[]{keyword}, null));
        if (IS_AD) {
            orClauses.add(Filter.createSubstringFilter("displayName", null, new String[]{keyword}, null));
        }
        Filter filter = Filter.createANDFilter(
                Filter.createEqualityFilter("objectClass", USER_OBJECTCLASS),
                Filter.createORFilter(orClauses));
        if (IS_AD) {
            // 排除计算机账号(AD 的 computer 类继承自 user)
            filter = Filter.createANDFilter(filter,
                    Filter.createNOTFilter(Filter.createEqualityFilter("objectClass", "computer")));
        }
        return search(c, BASE_DN, SearchScope.SUB, filter);
    }

    static Entry pickUser(LDAPConnection c, String promptText) throws Exception {
        String keyword = prompt(promptText);
        if (keyword.isEmpty()) {
            return null;
        }
        return pickEntry(findUser(c, keyword), "用户");
    }

    /** 把成员DN解析成友好显示:姓名 (登录名) [DN] —— 失败则原样返回DN */
    static String resolveMemberDisplay(LDAPConnection c, String dn) {
        try {
            Entry e = searchOne(c, dn, SearchScope.BASE,
                    Filter.createPresenceFilter("objectClass"), "cn", LOGIN_ATTR);
            if (e != null) {
                String cn = e.getAttributeValue("cn") == null ? "" : e.getAttributeValue("cn");
                String login = e.getAttributeValue(LOGIN_ATTR) == null ? "" : e.getAttributeValue(LOGIN_ATTR);
                String label = !cn.isEmpty() ? cn : login;
                if (!login.isEmpty() && !login.equals(cn)) {
                    label = cn + " (" + login + ")";
                }
                return label + "    [" + dn + "]";
            }
        } catch (LDAPException ignored) {
        }
        return dn;
    }

    // ---------------- 1. 连接与认证测试 ----------------

    static void opTestConnection() throws Exception {
        System.out.println("\n>>> 开始连接测试:" + LDAP_SERVER + ":" + LDAP_PORT
                + " (SSL=" + USE_SSL + ", StartTLS=" + USE_STARTTLS + ")");
        closeConnection();

        System.out.print("[1/3] 建立连接与认证 ... ");
        LDAPConnection c = getConnection();
        System.out.println("OK");
        if (isEncrypted()) {
            System.out.println("      加密连接已启用(信任所有证书)");
        }

        System.out.println("[2/3] 读取服务器信息(RootDSE) ...");
        RootDSE root = c.getRootDSE();
        if (root != null) {
            for (String name : new String[]{"vendorName", "vendorVersion", "namingContexts",
                                            "defaultNamingContext", "supportedLDAPVersion"}) {
                String[] values = root.getAttributeValues(name);
                if (values != null && values.length > 0) {
                    System.out.println("      " + name + ": " + String.join(", ", values));
                }
            }
            // 根据服务器信息提示 SERVER_TYPE 配置是否合适
            boolean looksAd = root.getAttributeValue("defaultNamingContext") != null
                    || (root.getVendorName() != null && root.getVendorName().contains("Microsoft"));
            if (looksAd && !IS_AD) {
                System.out.println("      [提示] 该服务器看起来像 AD,建议把 SERVER_TYPE 改为 \"ad\"");
            }
            if (!looksAd && IS_AD) {
                System.out.println("      [提示] 该服务器看起来不像 AD,建议把 SERVER_TYPE 改为 \"openldap\"");
            }
        }

        System.out.print("[3/3] BASE_DN 可用性检查 ... ");
        searchOne(c, BASE_DN, SearchScope.BASE, Filter.createPresenceFilter("objectClass"), "objectClass");
        System.out.println("OK," + BASE_DN + " 存在");

        System.out.println("\n全部通过,连接正常!");
    }

    // ---------------- 2. 查询用户 ----------------

    static void opSearchUser() throws Exception {
        LDAPConnection c = getConnection();
        String keyword = prompt("请输入登录名/姓名/邮箱关键字: ");
        if (keyword.isEmpty()) {
            return;
        }
        List<SearchResultEntry> entries = findUser(c, keyword);
        if (entries.isEmpty()) {
            System.out.println("未找到匹配的用户。");
            return;
        }
        System.out.println("\n共找到 " + entries.size() + " 个用户:");
        for (int i = 0; i < entries.size(); i++) {
            Entry e = entries.get(i);
            String login = nullToEmpty(e.getAttributeValue(LOGIN_ATTR));
            String cn = nullToEmpty(e.getAttributeValue("cn"));
            String mail = nullToEmpty(e.getAttributeValue("mail"));
            String line = "  " + (i + 1) + ". " + cn + " (" + login + ")";
            if (!mail.isEmpty()) {
                line += "  <" + mail + ">";
            }
            System.out.println(line);
        }
        String raw = prompt("\n输入序号查看详情(回车跳过): ");
        try {
            int index = Integer.parseInt(raw);
            if (index >= 1 && index <= entries.size()) {
                showEntry(entries.get(index - 1), "用户详情");
            }
        } catch (NumberFormatException ignored) {
        }
    }

    // ---------------- 3. 新建用户 ----------------

    static void opAddUser() throws Exception {
        LDAPConnection c = getConnection();
        System.out.println("\n>>> 新建用户(当前服务器类型: " + (IS_AD ? "AD" : "OpenLDAP") + ")");
        String username = prompt("登录名(" + LOGIN_ATTR + "): ");
        if (username.isEmpty()) {
            System.out.println("登录名不能为空。");
            return;
        }
        String cn = prompt("姓名/显示名(cn,回车同登录名): ");
        if (cn.isEmpty()) {
            cn = username;
        }
        String mail = prompt("邮箱(可回车跳过): ");
        String phone = prompt("电话(可回车跳过): ");
        String ou = prompt("存放容器(回车默认 " + USER_OU + "): ");
        if (ou.isEmpty()) {
            ou = USER_OU;
        }

        List<Attribute> attrs = new ArrayList<>();
        attrs.add(new Attribute("objectClass", USER_CLASSES));
        attrs.add(new Attribute("cn", cn));
        attrs.add(new Attribute(LOGIN_ATTR, username));
        if (!mail.isEmpty()) {
            attrs.add(new Attribute("mail", mail));
        }
        if (!phone.isEmpty()) {
            attrs.add(new Attribute("telephoneNumber", phone));
        }

        String dn;
        if (IS_AD) {
            dn = "cn=" + rdnEscape(cn) + "," + ou;
            attrs.add(new Attribute("displayName", cn));
        } else {
            String sn = prompt("姓(sn,OpenLDAP必填,回车同姓名): ");
            attrs.add(new Attribute("sn", sn.isEmpty() ? cn : sn));
            dn = "uid=" + rdnEscape(username) + "," + ou;
        }

        String pw1 = readPassword("初始密码(回车跳过,稍后可用菜单6设置): ");
        if (!pw1.isEmpty()) {
            String pw2 = readPassword("再次输入确认: ");
            if (!pw1.equals(pw2)) {
                System.out.println("两次输入的密码不一致,已取消。");
                return;
            }
        }

        c.add(dn, attrs);
        System.out.println("用户创建成功:" + dn);

        if (!pw1.isEmpty()) {
            if (IS_AD) {
                // AD 设置密码必须走加密连接,unicodePwd 为带引号的 UTF-16LE 编码
                if (!isEncrypted()) {
                    System.out.println("[警告] 未使用加密连接,AD 不允许设置密码。");
                    System.out.println("       账号已创建但处于禁用状态;请开启 USE_SSL(636端口) 后用菜单6设置密码。");
                    return;
                }
                byte[] pwdBytes = ("\"" + pw1 + "\"").getBytes(StandardCharsets.UTF_16LE);
                c.modify(dn, new Modification(ModificationType.REPLACE, "unicodePwd", pwdBytes));
                // 512 = NORMAL_ACCOUNT(启用),AD 新建用户默认是禁用状态
                c.modify(dn, new Modification(ModificationType.REPLACE, "userAccountControl", "512"));
                System.out.println("密码已设置,账号已启用。");
            } else {
                c.modify(dn, new Modification(ModificationType.REPLACE, "userPassword", pw1));
                System.out.println("密码已设置。");
            }
        } else if (IS_AD) {
            System.out.println("[提示] 未设置密码,AD 账号当前为禁用状态;设置密码后可用菜单7启用。");
        }
    }

    // ---------------- 4. 修改用户 ----------------

    static void opModifyUser() throws Exception {
        LDAPConnection c = getConnection();
        Entry entry = pickUser(c, "请输入登录名或姓名关键字: ");
        if (entry == null) {
            return;
        }
        String dn = entry.getDN();
        System.out.println("\n请输入要修改的属性,格式:属性名=新值(如 mail=zhangsan@test.com)");
        System.out.println("值为空表示删除该属性;直接回车结束输入。");
        List<Modification> changes = new ArrayList<>();
        while (true) {
            String line = prompt("属性=值: ");
            if (line.isEmpty()) {
                break;
            }
            int eq = line.indexOf('=');
            if (eq < 0) {
                System.out.println("格式不正确,请使用 属性名=新值");
                continue;
            }
            String name = line.substring(0, eq).trim();
            String value = line.substring(eq + 1).trim();
            if (name.isEmpty()) {
                System.out.println("属性名不能为空。");
                continue;
            }
            if (value.isEmpty()) {
                changes.add(new Modification(ModificationType.REPLACE, name)); // 空值=删除该属性
            } else {
                changes.add(new Modification(ModificationType.REPLACE, name, value));
            }
        }
        if (changes.isEmpty()) {
            System.out.println("没有需要修改的内容。");
            return;
        }
        c.modify(dn, changes);
        System.out.println("修改成功。");
        showEntry(searchOne(c, dn, SearchScope.BASE, Filter.createPresenceFilter("objectClass")), "修改后的用户信息");
    }

    // ---------------- 5. 删除用户 ----------------

    static void opDeleteUser() throws Exception {
        LDAPConnection c = getConnection();
        Entry entry = pickUser(c, "请输入登录名或姓名关键字: ");
        if (entry == null) {
            return;
        }
        String confirm = prompt("确认删除用户 " + entry.getDN() + " ?此操作不可恢复,输入 yes 确认: ");
        if (!"yes".equals(confirm)) {
            System.out.println("已取消。");
            return;
        }
        c.delete(entry.getDN());
        System.out.println("用户已删除。");
    }

    // ---------------- 6. 重置用户密码 ----------------

    static void opResetPassword() throws Exception {
        LDAPConnection c = getConnection();
        Entry entry = pickUser(c, "请输入登录名或姓名关键字: ");
        if (entry == null) {
            return;
        }
        String dn = entry.getDN();
        if (IS_AD && !isEncrypted()) {
            System.out.println("[错误] AD 重置密码必须使用加密连接!");
            System.out.println("       请修改配置:USE_SSL=true 且 LDAP_PORT=636(或 USE_STARTTLS=true),然后重试。");
            return;
        }
        String pw1 = readPassword("请输入新密码: ");
        if (pw1.isEmpty()) {
            System.out.println("密码不能为空。");
            return;
        }
        String pw2 = readPassword("再次输入确认: ");
        if (!pw1.equals(pw2)) {
            System.out.println("两次输入的密码不一致,已取消。");
            return;
        }
        if (IS_AD) {
            byte[] pwdBytes = ("\"" + pw1 + "\"").getBytes(StandardCharsets.UTF_16LE);
            c.modify(dn, new Modification(ModificationType.REPLACE, "unicodePwd", pwdBytes));
        } else {
            c.modify(dn, new Modification(ModificationType.REPLACE, "userPassword", pw1));
        }
        System.out.println("密码重置成功。");
    }

    // ---------------- 7. 启用/禁用账号(仅 AD) ----------------

    static void opToggleAccount() throws Exception {
        if (!IS_AD) {
            System.out.println("该功能仅适用于 AD(OpenLDAP 没有账号启用/禁用属性)。");
            return;
        }
        LDAPConnection c = getConnection();
        Entry entry = pickUser(c, "请输入登录名或姓名关键字: ");
        if (entry == null) {
            return;
        }
        String dn = entry.getDN();
        Entry fresh = searchOne(c, dn, SearchScope.BASE,
                Filter.createPresenceFilter("objectClass"), "userAccountControl", LOGIN_ATTR);
        int uac = fresh.getAttributeValueAsInteger("userAccountControl");
        boolean disabled = (uac & 0x2) != 0;
        String login = nullToEmpty(fresh.getAttributeValue(LOGIN_ATTR));
        System.out.println("账号 " + login + " 当前状态:" + (disabled ? "已禁用" : "已启用"));
        String action = disabled ? "启用" : "禁用";
        if (!"y".equalsIgnoreCase(prompt("确认" + action + "该账号?(y/n): "))) {
            return;
        }
        c.modify(dn, new Modification(ModificationType.REPLACE, "userAccountControl",
                String.valueOf(uac ^ 0x2)));
        System.out.println("账号已" + action + "。");
    }

    // ---------------- 8. 组织(OU)管理 ----------------

    static List<SearchResultEntry> listOus(LDAPConnection c) throws LDAPException {
        List<SearchResultEntry> entries = search(c, BASE_DN, SearchScope.SUB,
                Filter.createEqualityFilter("objectClass", "organizationalUnit"), "ou");
        entries.sort((a, b) -> a.getDN().compareToIgnoreCase(b.getDN()));
        return entries;
    }

    static void ouList(LDAPConnection c) throws Exception {
        List<SearchResultEntry> entries = listOus(c);
        if (entries.isEmpty()) {
            System.out.println("没有找到任何组织单元(OU)。");
            return;
        }
        System.out.println("\n共 " + entries.size() + " 个组织单元:");
        for (Entry e : entries) {
            System.out.printf("  %-20s %s%n", nullToEmpty(e.getAttributeValue("ou")), e.getDN());
        }
    }

    static void ouCreate(LDAPConnection c) throws Exception {
        String name = prompt("新组织名称(ou): ");
        if (name.isEmpty()) {
            return;
        }
        String parent = prompt("父容器(回车默认 " + BASE_DN + "): ");
        if (parent.isEmpty()) {
            parent = BASE_DN;
        }
        String dn = "ou=" + rdnEscape(name) + "," + parent;
        c.add(dn, new Attribute("objectClass", "top", "organizationalUnit"),
                  new Attribute("ou", name));
        System.out.println("组织创建成功:" + dn);
    }

    static void ouRename(LDAPConnection c) throws Exception {
        Entry entry = pickEntry(listOus(c), "组织");
        if (entry == null) {
            return;
        }
        String newName = prompt("请输入新名称(ou): ");
        if (newName.isEmpty()) {
            return;
        }
        c.modifyDN(entry.getDN(), "ou=" + rdnEscape(newName), true);
        System.out.println("重命名成功。");
    }

    static void ouDelete(LDAPConnection c) throws Exception {
        Entry entry = pickEntry(listOus(c), "组织");
        if (entry == null) {
            return;
        }
        String dn = entry.getDN();
        List<SearchResultEntry> children = search(c, dn, SearchScope.ONE,
                Filter.createPresenceFilter("objectClass"), "cn");
        if (!children.isEmpty()) {
            System.out.println("该组织下还有 " + children.size() + " 个子条目,不能删除。请先清空。");
            return;
        }
        if (!"yes".equals(prompt("确认删除组织 " + dn + " ?输入 yes 确认: "))) {
            System.out.println("已取消。");
            return;
        }
        c.delete(dn);
        System.out.println("组织已删除。");
    }

    static void ouMenu() {
        while (true) {
            System.out.println("\n------ 组织(OU)管理 ------");
            System.out.println(" 1. 查看组织列表");
            System.out.println(" 2. 新建组织");
            System.out.println(" 3. 重命名组织");
            System.out.println(" 4. 删除组织");
            System.out.println(" 0. 返回上级菜单");
            try {
                String choice = prompt("请选择: ");
                switch (choice) {
                    case "1": ouList(getConnection()); break;
                    case "2": ouCreate(getConnection()); break;
                    case "3": ouRename(getConnection()); break;
                    case "4": ouDelete(getConnection()); break;
                    case "0": return;
                    default: break;
                }
            } catch (Exception e) {
                handleError(e);
            }
        }
    }

    // ---------------- 9. 角色(组)管理 ----------------

    static List<SearchResultEntry> listGroups(LDAPConnection c) throws LDAPException {
        List<SearchResultEntry> entries = search(c, BASE_DN, SearchScope.SUB,
                Filter.createEqualityFilter("objectClass", GROUP_OBJECTCLASS), "cn", "description");
        entries.sort((a, b) -> a.getDN().compareToIgnoreCase(b.getDN()));
        return entries;
    }

    static void groupList(LDAPConnection c) throws Exception {
        List<SearchResultEntry> entries = listGroups(c);
        if (entries.isEmpty()) {
            System.out.println("没有找到任何角色(组)。");
            return;
        }
        System.out.println("\n共 " + entries.size() + " 个角色(组):");
        for (Entry e : entries) {
            System.out.printf("  %-20s %s%n", nullToEmpty(e.getAttributeValue("cn")), e.getDN());
        }
    }

    static void groupViewMembers(LDAPConnection c) throws Exception {
        Entry entry = pickEntry(listGroups(c), "角色");
        if (entry == null) {
            return;
        }
        Entry group = searchOne(c, entry.getDN(), SearchScope.BASE,
                Filter.createPresenceFilter("objectClass"), MEMBER_ATTR);
        String[] members = group.getAttributeValues(MEMBER_ATTR);
        if (members == null || members.length == 0) {
            System.out.println("该角色暂无成员。");
            return;
        }
        System.out.println("\n角色 " + entry.getDN() + " 共 " + members.length + " 个成员:");
        for (String m : members) {
            System.out.println("  - " + resolveMemberDisplay(c, m));
        }
    }

    static void groupCreate(LDAPConnection c) throws Exception {
        String name = prompt("角色名称(cn): ");
        if (name.isEmpty()) {
            return;
        }
        String desc = prompt("描述(可回车跳过): ");
        String ou = prompt("存放容器(回车默认 " + GROUP_OU + "): ");
        if (ou.isEmpty()) {
            ou = GROUP_OU;
        }
        String dn = "cn=" + rdnEscape(name) + "," + ou;
        List<Attribute> attrs = new ArrayList<>();
        attrs.add(new Attribute("objectClass", GROUP_CLASSES));
        attrs.add(new Attribute("cn", name));
        if (!desc.isEmpty()) {
            attrs.add(new Attribute("description", desc));
        }
        if (IS_AD) {
            attrs.add(new Attribute("sAMAccountName", name));
            attrs.add(new Attribute("groupType", AD_GROUP_TYPE));   // 全局安全组
        } else {
            // OpenLDAP 的 groupOfNames 要求创建时至少有一个成员
            String kw = prompt("初始成员登录名(回车则使用绑定账号 " + BIND_DN + "): ");
            if (!kw.isEmpty()) {
                Entry user = pickEntry(findUser(c, kw), "用户");
                if (user == null) {
                    System.out.println("未选择初始成员,已取消创建。");
                    return;
                }
                attrs.add(new Attribute("member", user.getDN()));
            } else {
                attrs.add(new Attribute("member", BIND_DN));
                System.out.println("[提示] 已把绑定账号设为初始成员(groupOfNames 强制要求至少一名成员),");
                System.out.println("       之后可通过【移除成员】功能删除。");
            }
        }
        c.add(dn, attrs);
        System.out.println("角色创建成功:" + dn);
    }

    static void groupDelete(LDAPConnection c) throws Exception {
        Entry entry = pickEntry(listGroups(c), "角色");
        if (entry == null) {
            return;
        }
        if (!"yes".equals(prompt("确认删除角色 " + entry.getDN() + " ?输入 yes 确认: "))) {
            System.out.println("已取消。");
            return;
        }
        c.delete(entry.getDN());
        System.out.println("角色已删除。");
    }

    static void groupAddMember(LDAPConnection c) throws Exception {
        Entry entry = pickEntry(listGroups(c), "角色");
        if (entry == null) {
            return;
        }
        Entry user = pickUser(c, "请输入要加入的用户登录名或姓名关键字: ");
        if (user == null) {
            return;
        }
        c.modify(entry.getDN(), new Modification(ModificationType.ADD, MEMBER_ATTR, user.getDN()));
        System.out.println("已把 " + user.getDN() + " 加入角色。");
    }

    static void groupRemoveMember(LDAPConnection c) throws Exception {
        Entry entry = pickEntry(listGroups(c), "角色");
        if (entry == null) {
            return;
        }
        Entry group = searchOne(c, entry.getDN(), SearchScope.BASE,
                Filter.createPresenceFilter("objectClass"), MEMBER_ATTR);
        String[] members = group.getAttributeValues(MEMBER_ATTR);
        if (members == null || members.length == 0) {
            System.out.println("该角色暂无成员。");
            return;
        }
        System.out.println("\n当前成员:");
        for (int i = 0; i < members.length; i++) {
            System.out.println("  " + (i + 1) + ". " + resolveMemberDisplay(c, members[i]));
        }
        String raw = prompt("请输入要移除的成员序号(回车取消): ");
        int index;
        try {
            index = Integer.parseInt(raw);
        } catch (NumberFormatException e) {
            return;
        }
        if (index < 1 || index > members.length) {
            return;
        }
        String target = members[index - 1];
        if (!"y".equalsIgnoreCase(prompt("确认把 " + target + " 移出角色?(y/n): "))) {
            return;
        }
        c.modify(entry.getDN(), new Modification(ModificationType.DELETE, MEMBER_ATTR, target));
        System.out.println("成员已移除。");
    }

    static void groupMenu() {
        while (true) {
            System.out.println("\n------ 角色(组)管理 ------");
            System.out.println(" 1. 查看角色列表");
            System.out.println(" 2. 查看角色成员");
            System.out.println(" 3. 新建角色");
            System.out.println(" 4. 删除角色");
            System.out.println(" 5. 添加成员");
            System.out.println(" 6. 移除成员");
            System.out.println(" 0. 返回上级菜单");
            try {
                String choice = prompt("请选择: ");
                switch (choice) {
                    case "1": groupList(getConnection()); break;
                    case "2": groupViewMembers(getConnection()); break;
                    case "3": groupCreate(getConnection()); break;
                    case "4": groupDelete(getConnection()); break;
                    case "5": groupAddMember(getConnection()); break;
                    case "6": groupRemoveMember(getConnection()); break;
                    case "0": return;
                    default: break;
                }
            } catch (Exception e) {
                handleError(e);
            }
        }
    }

    // ======================================================================
    //                  LDAP 结果码中文映射 与 小工具函数
    // ======================================================================

    static final Map<Integer, String> RESULT_CODES = new HashMap<>();
    static {
        RESULT_CODES.put(0, "成功");
        RESULT_CODES.put(1, "服务器内部错误(operationsError)");
        RESULT_CODES.put(2, "协议错误(protocolError)");
        RESULT_CODES.put(3, "查询超时(timeLimitExceeded)");
        RESULT_CODES.put(4, "结果条数超限(sizeLimitExceeded)");
        RESULT_CODES.put(7, "服务器不支持该认证方式(authMethodNotSupported)");
        RESULT_CODES.put(8, "需要加密连接(strongerAuthRequired)");
        RESULT_CODES.put(32, "对象不存在(noSuchObject),请检查DN或BASE_DN是否正确");
        RESULT_CODES.put(34, "DN语法错误(invalidDNSyntax)");
        RESULT_CODES.put(49, "账号或密码错误(invalidCredentials)");
        RESULT_CODES.put(50, "权限不足(insufficientAccessRights)");
        RESULT_CODES.put(53, "服务器拒绝执行(unwillingToPerform):AD上常见原因:未加密连接改密码、密码不符合复杂度策略");
        RESULT_CODES.put(65, "对象类违规(objectClassViolation):缺少必填属性或属性不属于对象类");
        RESULT_CODES.put(66, "不允许删除非叶子节点(notAllowedOnNonLeaf):请先删除子节点");
        RESULT_CODES.put(68, "条目已存在(entryAlreadyExists)");
        RESULT_CODES.put(80, "未知错误(other)");
        RESULT_CODES.put(81, "无法连接服务器(serverDown),请检查地址/端口/网络");
        RESULT_CODES.put(91, "连接错误(connectError)");
    }

    /** 统一错误处理;连接类错误重置连接,下次操作自动重连 */
    static void handleError(Exception e) {
        if (e instanceof LDAPException) {
            LDAPException le = (LDAPException) e;
            int code = le.getResultCode() == null ? -1 : le.getResultCode().intValue();
            String desc = RESULT_CODES.getOrDefault(code, "结果码 " + code);
            String diag = le.getDiagnosticMessage();
            System.out.println("[失败] LDAP错误[" + code + "] " + desc
                    + (diag != null && !diag.isEmpty() ? ";服务器返回:" + diag : ""));
            if (conn != null && !conn.isConnected()) {
                conn = null;    // 下次操作自动重连
            }
        } else {
            System.out.println("[失败] " + e.getMessage());
        }
    }

    static String prompt(String message) throws Exception {
        System.out.print(message);
        String line = IN.readLine();
        if (line == null) {          // EOF(如管道输入结束)
            System.out.println();
            System.exit(0);
        }
        return line.trim();
    }

    /** 读取密码:控制台环境不回显;无控制台(如IDE/管道)时退化为普通输入 */
    static String readPassword(String message) throws Exception {
        Console console = System.console();
        if (console != null) {
            char[] pw = console.readPassword("%s", message);
            return pw == null ? "" : new String(pw);
        }
        return prompt(message);
    }

    /** 转义 DN 中 RDN 值的特殊字符(构造新条目DN时使用) */
    static String rdnEscape(String s) {
        s = s.replace("\\", "\\\\")
             .replace(",", "\\,")
             .replace("+", "\\+")
             .replace("\"", "\\\"")
             .replace("<", "\\<")
             .replace(">", "\\>")
             .replace(";", "\\;")
             .replace("=", "\\=");
        if (s.startsWith(" ") || s.startsWith("#")) {
            s = "\\" + s;
        }
        if (s.endsWith(" ")) {
            s = s.substring(0, s.length() - 1) + "\\ ";
        }
        return s;
    }

    static String nullToEmpty(String s) {
        return s == null ? "" : s;
    }

    static void pause() throws Exception {
        prompt("\n按回车键返回菜单...");
    }

    // ======================================================================
    //                            主菜单
    // ======================================================================

    public static void main(String[] args) {
        System.out.println("====================================================");
        System.out.println("     LDAP 客户端测试工具(基于 UnboundID LDAP SDK)");
        System.out.println("====================================================");
        while (true) {
            String status = (conn != null && conn.isConnected()) ? "已连接" : "未连接";
            String enc = USE_SSL ? "LDAPS" : (USE_STARTTLS ? "StartTLS" : "明文");
            System.out.println("\n服务器: " + LDAP_SERVER + ":" + LDAP_PORT + " (" + enc + ")"
                    + "  类型: " + SERVER_TYPE + "  状态: " + status);
            System.out.println("----------------------------------------------------");
            System.out.println(" 1. 连接与认证测试");
            System.out.println(" 2. 查询用户");
            System.out.println(" 3. 新建用户");
            System.out.println(" 4. 修改用户");
            System.out.println(" 5. 删除用户");
            System.out.println(" 6. 重置用户密码");
            System.out.println(" 7. 启用/禁用账号(仅AD)");
            System.out.println(" 8. 组织(OU)管理");
            System.out.println(" 9. 角色(组)管理");
            System.out.println(" 0. 退出");
            String choice;
            try {
                choice = prompt("请选择: ");
            } catch (Exception e) {
                break;
            }
            try {
                switch (choice) {
                    case "1": opTestConnection(); pause(); break;
                    case "2": opSearchUser(); pause(); break;
                    case "3": opAddUser(); pause(); break;
                    case "4": opModifyUser(); pause(); break;
                    case "5": opDeleteUser(); pause(); break;
                    case "6": opResetPassword(); pause(); break;
                    case "7": opToggleAccount(); pause(); break;
                    case "8": ouMenu(); break;
                    case "9": groupMenu(); break;
                    case "0":
                        closeConnection();
                        System.out.println("再见!");
                        return;
                    default:
                        System.out.println("无效的选择。");
                }
            } catch (Exception e) {
                handleError(e);
            }
        }
    }
}

    

Python代码实例

language 复制代码
      
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LDAP 客户端测试工具(基于 python-ldap 库,单文件运行)
======================================================

用途:
    连接 AD / OpenLDAP 等 LDAP 服务器,测试连接与认证,
    并对 用户 / 组织(OU) / 角色(组) 进行增删改查。
    (本文件为 python-ldap 版本,与同目录 ldap_client.py 的 ldap3 版本功能一致,
      用于对比两个库的代码风格。)

运行方式:
    python3 ldap_client_pyldap.py

使用步骤:
    1. 先安装依赖库(只需一次):pip install python-ldap
       注意:python-ldap 是 C 扩展,Linux 需先安装编译环境及 openldap 开发头文件,
             Windows 建议使用第三方预编译 wheel。
    2. 修改下方"配置区"的服务器地址、账号、BASE_DN 等参数(每行都有注释)
    3. 运行脚本,按中文菜单提示操作即可

注意:
    - AD 重置用户密码必须使用加密连接(USE_SSL=True 或 USE_STARTTLS=True)。
    - 本工具仅做内网测试用途,默认不校验 TLS 证书(兼容自签名证书)。
"""

# ======================================================================
#                            配  置  区
#            (只需要改这里,每行右侧都有中文说明)
# ======================================================================

LDAP_SERVER   = "192.168.1.10"          # LDAP 服务器地址(IP 或域名)
LDAP_PORT     = 389                     # 端口:389=明文或StartTLS,636=LDAPS加密
USE_SSL       = False                   # True=使用LDAPS(端口一般改成636);AD改密码必须加密
USE_STARTTLS  = False                   # True=先明文连接再升级为TLS(端口保持389);与 USE_SSL 二选一
CONNECT_TIMEOUT = 8                     # 连接/操作超时时间(秒)

BIND_DN       = "cn=ND001,dc=ad,dc=com"   # 绑定(登录)账号;AD 也可填 "域名\\账号" 或 "账号@域名";留空则匿名绑定
BIND_PASSWORD = ""                      # 绑定账号密码;留空则运行时交互式输入(不回显,更安全)

BASE_DN       = "dc=ad,dc=com"     # 搜索根节点(目录的最顶层DN)

# 服务器类型:微软 AD 填 "ad";OpenLDAP 填 "openldap"
SERVER_TYPE   = "ad"

# 新建用户/角色默认存放的容器
USER_OU       = "ou=users,dc=example,dc=com"    # 新建用户的默认容器
GROUP_OU      = "ou=groups,dc=example,dc=com"   # 新建角色(组)的默认容器

SEARCH_PAGE_SIZE = 500                  # 分页查询每页条数(AD 单次查询上限1000,脚本自动翻页)

# ======================================================================
#                        以下为程序代码,一般无需修改
# ======================================================================

import getpass
import sys

try:
    import ldap
    import ldap.modlist
    from ldap.controls import SimplePagedResultsControl
except ImportError:
    sys.exit("未安装 python-ldap 库,请先执行:  pip install python-ldap")

IS_AD = SERVER_TYPE.strip().lower() == "ad"

# ======================================================================
#              业务操作层(屏蔽 AD 与 OpenLDAP 的属性差异)
# ======================================================================

LOGIN_ATTR = "sAMAccountName" if IS_AD else "uid"
USER_OBJECTCLASS = "user" if IS_AD else "inetOrgPerson"
USER_CLASSES = (["top", "person", "organizationalPerson", "user"] if IS_AD
                else ["top", "person", "organizationalPerson", "inetOrgPerson"])
GROUP_OBJECTCLASS = "group" if IS_AD else "groupOfNames"
GROUP_CLASSES = ["top", "group"] if IS_AD else ["top", "groupOfNames"]
MEMBER_ATTR = "member"

# AD 组类型:-2147483646 = 0x80000002 = 全局安全组(最常用的角色类型)
AD_GROUP_TYPE = "-2147483646"

_client = None          # 当前连接(全局单例)
_password_cache = None  # 交互输入的绑定密码缓存,避免反复输入


def get_client():
    """获取可用连接:未连接或已断开时自动重连并完成认证"""
    global _client, _password_cache
    if _client is not None:
        return _client
    scheme = "ldaps" if USE_SSL else "ldap"
    conn = ldap.initialize("%s://%s:%d" % (scheme, LDAP_SERVER, LDAP_PORT))
    conn.set_option(ldap.OPT_NETWORK_TIMEOUT, CONNECT_TIMEOUT)
    conn.set_option(ldap.OPT_TIMEOUT, CONNECT_TIMEOUT)
    # 内网测试:不校验服务器证书,兼容自签名证书
    conn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
    pw = BIND_PASSWORD or _password_cache
    if BIND_DN and not pw:
        pw = getpass.getpass("请输入绑定账号 %s 的密码: " % BIND_DN)
        _password_cache = pw
    try:
        if USE_STARTTLS and not USE_SSL:
            conn.start_tls_s()
        conn.simple_bind_s(BIND_DN, pw)
    except ldap.LDAPError as exc:
        raise wrap_ldap_error(exc)
    _client = conn
    return conn


def reset_client():
    global _client
    if _client is not None:
        try:
            _client.unbind_s()
        except Exception:
            pass
        _client = None


def do_search(c, base, scope, search_filter, attrs=None):
    """
    执行搜索(自动分页),返回 [{"dn": str, "attrs": {属性名: [值bytes...]}}, ...]
    scope 取 ldap.SCOPE_BASE / ldap.SCOPE_ONELEVEL / ldap.SCOPE_SUBTREE
    """
    page_ctrl = SimplePagedResultsControl(True, size=SEARCH_PAGE_SIZE, cookie="")
    entries = []
    try:
        while True:
            msgid = c.search_ext(base, scope, search_filter, attrs,
                                 serverctrls=[page_ctrl])
            _rtype, data, _msgid, server_ctrls = c.result3(msgid)
            for dn, attrs_dict in data:
                if dn is not None:          # dn 为 None 的是引用(referral),忽略
                    entries.append({"dn": dn, "attrs": attrs_dict})
            cookie = None
            for ctrl in server_ctrls:
                if ctrl.controlType == SimplePagedResultsControl.controlType:
                    cookie = ctrl.cookie
            if not cookie:
                break
            page_ctrl.cookie = cookie
    except ldap.LDAPError as exc:
        raise wrap_ldap_error(exc)
    return entries


def search_one(c, base, scope, search_filter, attrs=None):
    entries = do_search(c, base, scope, search_filter, attrs)
    return entries[0] if entries else None


def do_add(c, dn, attrs):
    """新建条目,attrs 为 {属性名: [值...]}"""
    modlist = ldap.modlist.addModlist(
        {name: to_bytes_list(values) for name, values in attrs.items()})
    try:
        c.add_s(dn, modlist)
    except ldap.LDAPError as exc:
        raise wrap_ldap_error(exc)


def do_delete(c, dn):
    try:
        c.delete_s(dn)
    except ldap.LDAPError as exc:
        raise wrap_ldap_error(exc)


def do_modify(c, dn, changes):
    """修改条目。changes 为 [(ldap.MOD_*, 属性名, [值...]), ...]"""
    modlist = [(op, name, to_bytes_list(values)) for op, name, values in changes]
    try:
        c.modify_s(dn, modlist)
    except ldap.LDAPError as exc:
        raise wrap_ldap_error(exc)


def do_rename(c, dn, new_rdn):
    try:
        c.rename_s(dn, new_rdn)
    except ldap.LDAPError as exc:
        raise wrap_ldap_error(exc)


# ---------------- 显示与选择辅助 ----------------

def show_entry(entry, title="条目详情"):
    print("\n----- %s -----" % title)
    print("DN: %s" % entry["dn"])
    for name in sorted(entry["attrs"]):
        values = [fmt_val(v) for v in entry["attrs"][name]]
        print("  %s: %s" % (name, ", ".join(values)))
    print("-" * 40)


def pick_entry(entries, what="条目"):
    """从搜索结果中让用户挑选一个,返回条目或 None"""
    if not entries:
        print("未找到匹配的%s。" % what)
        return None
    if len(entries) == 1:
        show_entry(entries[0])
        if input("确认操作该%s?(y/n): " % what).strip().lower() == "y":
            return entries[0]
        return None
    print("\n找到 %d 个匹配的%s:" % (len(entries), what))
    for i, e in enumerate(entries, 1):
        name = fmt_val(e["attrs"].get("cn", e["attrs"].get("ou", [b""]))[0])
        print("  %d. %s    (%s)" % (i, name, e["dn"]))
    raw = input("请输入序号选择(回车取消): ").strip()
    if not raw.isdigit() or not (1 <= int(raw) <= len(entries)):
        return None
    return entries[int(raw) - 1]


def find_user(c, keyword):
    """按登录名/姓名/邮箱模糊搜索用户"""
    kw = _filter_escape(keyword)
    clauses = ["(%s=*%s*)" % (LOGIN_ATTR, kw),
               "(cn=*%s*)" % kw,
               "(mail=*%s*)" % kw]
    if IS_AD:
        clauses.append("(displayName=*%s*)" % kw)
    ldap_filter = "(&(objectClass=%s)(|%s))" % (USER_OBJECTCLASS, "".join(clauses))
    if IS_AD:
        ldap_filter = "(&%s(!(objectClass=computer)))" % ldap_filter  # 排除计算机账号
    return do_search(c, BASE_DN, ldap.SCOPE_SUBTREE, ldap_filter)


def pick_user(c, prompt="请输入登录名或姓名关键字: "):
    keyword = input(prompt).strip()
    if not keyword:
        return None
    return pick_entry(find_user(c, keyword), what="用户")


def resolve_member_display(c, dn):
    """把成员DN解析成友好显示:姓名 (登录名) [DN] —— 失败则原样返回DN"""
    try:
        e = search_one(c, dn, ldap.SCOPE_BASE, "(objectClass=*)", ["cn", LOGIN_ATTR])
        if e:
            cn = fmt_val(e["attrs"].get("cn", [b""])[0])
            login = fmt_val(e["attrs"].get(LOGIN_ATTR, [b""])[0])
            label = cn or login
            if login and login != cn:
                label = "%s (%s)" % (cn, login)
            return "%s    [%s]" % (label, dn)
    except LdapError:
        pass
    return dn


# ---------------- 1. 连接与认证测试 ----------------

def op_test_connection():
    print("\n>>> 开始连接测试:%s:%d (SSL=%s, StartTLS=%s)" %
          (LDAP_SERVER, LDAP_PORT, USE_SSL, USE_STARTTLS))
    reset_client()

    print("[1/3] 建立连接与认证 ... ", end="")
    c = get_client()     # get_client 内部已完成 TCP/TLS/Bind
    print("OK")
    if USE_SSL or USE_STARTTLS:
        print("      加密连接已启用(不校验证书)")

    print("[2/3] 读取服务器信息(RootDSE) ...")
    root = search_one(c, "", ldap.SCOPE_BASE, "(objectClass=*)",
                      ["vendorName", "vendorVersion", "namingContexts",
                       "defaultNamingContext", "supportedLDAPVersion"])
    if root:
        for name in sorted(root["attrs"]):
            values = [fmt_val(v) for v in root["attrs"][name]]
            print("      %s: %s" % (name, ", ".join(values)))
        joined = " ".join(fmt_val(v) for vs in root["attrs"].values() for v in vs)
        looks_ad = "Microsoft" in joined or "defaultNamingContext" in root["attrs"]
        if looks_ad and not IS_AD:
            print("      [提示] 该服务器看起来像 AD,建议把 SERVER_TYPE 改为 \"ad\"")
        if not looks_ad and IS_AD:
            print("      [提示] 该服务器看起来不像 AD,建议把 SERVER_TYPE 改为 \"openldap\"")

    print("[3/3] BASE_DN 可用性检查 ... ", end="")
    search_one(c, BASE_DN, ldap.SCOPE_BASE, "(objectClass=*)", ["objectClass"])
    print("OK,%s 存在" % BASE_DN)

    print("\n全部通过,连接正常!")


# ---------------- 2. 查询用户 ----------------

def op_search_user():
    c = get_client()
    keyword = input("请输入登录名/姓名/邮箱关键字: ").strip()
    if not keyword:
        return
    entries = find_user(c, keyword)
    if not entries:
        print("未找到匹配的用户。")
        return
    print("\n共找到 %d 个用户:" % len(entries))
    for i, e in enumerate(entries, 1):
        a = e["attrs"]
        login = fmt_val(a.get(LOGIN_ATTR, [b""])[0])
        cn = fmt_val(a.get("cn", [b""])[0])
        mail = fmt_val(a.get("mail", [b""])[0])
        line = "  %d. %s (%s)" % (i, cn, login)
        if mail:
            line += "  <%s>" % mail
        print(line)
    raw = input("\n输入序号查看详情(回车跳过): ").strip()
    if raw.isdigit() and 1 <= int(raw) <= len(entries):
        show_entry(entries[int(raw) - 1], "用户详情")


# ---------------- 3. 新建用户 ----------------

def op_add_user():
    c = get_client()
    print("\n>>> 新建用户(当前服务器类型: %s)" % ("AD" if IS_AD else "OpenLDAP"))
    username = input("登录名(%s): " % LOGIN_ATTR).strip()
    if not username:
        print("登录名不能为空。")
        return
    cn = input("姓名/显示名(cn,回车同登录名): ").strip() or username
    mail = input("邮箱(可回车跳过): ").strip()
    phone = input("电话(可回车跳过): ").strip()
    ou = input("存放容器(回车默认 %s): " % USER_OU).strip() or USER_OU

    attrs = {"objectClass": USER_CLASSES, "cn": [cn], LOGIN_ATTR: [username]}
    if mail:
        attrs["mail"] = [mail]
    if phone:
        attrs["telephoneNumber"] = [phone]

    if IS_AD:
        dn = "cn=%s,%s" % (rdn_escape(cn), ou)
        attrs["displayName"] = [cn]
    else:
        sn = input("姓(sn,OpenLDAP必填,回车同姓名): ").strip() or cn
        attrs["sn"] = [sn]
        dn = "uid=%s,%s" % (rdn_escape(username), ou)

    pw1 = getpass.getpass("初始密码(回车跳过,稍后可用菜单6设置): ")
    if pw1:
        pw2 = getpass.getpass("再次输入确认: ")
        if pw1 != pw2:
            print("两次输入的密码不一致,已取消。")
            return

    do_add(c, dn, attrs)
    print("用户创建成功:%s" % dn)

    if pw1:
        if IS_AD:
            # AD 设置密码必须走加密连接,unicodePwd 为带引号的 UTF-16LE 编码
            if not (USE_SSL or USE_STARTTLS):
                print("[警告] 未使用加密连接,AD 不允许设置密码。")
                print("       账号已创建但处于禁用状态;请开启 USE_SSL(636端口) 后用菜单6设置密码。")
                return
            do_modify(c, dn, [(ldap.MOD_REPLACE, "unicodePwd",
                               [('"%s"' % pw1).encode("utf-16-le")])])
            # 512 = NORMAL_ACCOUNT(启用),AD 新建用户默认是禁用状态
            do_modify(c, dn, [(ldap.MOD_REPLACE, "userAccountControl", ["512"])])
            print("密码已设置,账号已启用。")
        else:
            do_modify(c, dn, [(ldap.MOD_REPLACE, "userPassword", [pw1])])
            print("密码已设置。")
    elif IS_AD:
        print("[提示] 未设置密码,AD 账号当前为禁用状态;设置密码后可用菜单7启用。")


# ---------------- 4. 修改用户 ----------------

def op_modify_user():
    c = get_client()
    entry = pick_user(c)
    if not entry:
        return
    dn = entry["dn"]
    print("\n请输入要修改的属性,格式:属性名=新值(如 mail=zhangsan@test.com)")
    print("值为空表示删除该属性;直接回车结束输入。")
    changes = []
    while True:
        line = input("属性=值: ").strip()
        if not line:
            break
        if "=" not in line:
            print("格式不正确,请使用 属性名=新值")
            continue
        name, value = line.split("=", 1)
        name = name.strip()
        value = value.strip()
        if not name:
            print("属性名不能为空。")
            continue
        changes.append((ldap.MOD_REPLACE, name, [value] if value else []))  # 空值=删除该属性
    if not changes:
        print("没有需要修改的内容。")
        return
    do_modify(c, dn, changes)
    print("修改成功。")
    show_entry(search_one(c, dn, ldap.SCOPE_BASE, "(objectClass=*)"), "修改后的用户信息")


# ---------------- 5. 删除用户 ----------------

def op_delete_user():
    c = get_client()
    entry = pick_user(c)
    if not entry:
        return
    confirm = input("确认删除用户 %s ?此操作不可恢复,输入 yes 确认: " % entry["dn"]).strip()
    if confirm != "yes":
        print("已取消。")
        return
    do_delete(c, entry["dn"])
    print("用户已删除。")


# ---------------- 6. 重置用户密码 ----------------

def op_reset_password():
    c = get_client()
    entry = pick_user(c)
    if not entry:
        return
    dn = entry["dn"]
    if IS_AD and not (USE_SSL or USE_STARTTLS):
        print("[错误] AD 重置密码必须使用加密连接!")
        print("       请修改配置:USE_SSL=True 且 LDAP_PORT=636(或 USE_STARTTLS=True),然后重试。")
        return
    pw1 = getpass.getpass("请输入新密码: ")
    if not pw1:
        print("密码不能为空。")
        return
    pw2 = getpass.getpass("再次输入确认: ")
    if pw1 != pw2:
        print("两次输入的密码不一致,已取消。")
        return
    if IS_AD:
        do_modify(c, dn, [(ldap.MOD_REPLACE, "unicodePwd",
                           [('"%s"' % pw1).encode("utf-16-le")])])
    else:
        do_modify(c, dn, [(ldap.MOD_REPLACE, "userPassword", [pw1])])
    print("密码重置成功。")


# ---------------- 7. 启用/禁用账号(仅 AD) ----------------

def op_toggle_account():
    if not IS_AD:
        print("该功能仅适用于 AD(OpenLDAP 没有账号启用/禁用属性)。")
        return
    c = get_client()
    entry = pick_user(c)
    if not entry:
        return
    dn = entry["dn"]
    fresh = search_one(c, dn, ldap.SCOPE_BASE, "(objectClass=*)",
                       ["userAccountControl", LOGIN_ATTR])
    uac = int(fmt_val(fresh["attrs"]["userAccountControl"][0]))
    disabled = bool(uac & 0x2)
    login = fmt_val(fresh["attrs"].get(LOGIN_ATTR, [b""])[0])
    print("账号 %s 当前状态:%s" % (login, "已禁用" if disabled else "已启用"))
    action = "启用" if disabled else "禁用"
    if input("确认%s该账号?(y/n): " % action).strip().lower() != "y":
        return
    do_modify(c, dn, [(ldap.MOD_REPLACE, "userAccountControl", [str(uac ^ 0x2)])])
    print("账号已%s。" % action)


# ---------------- 8. 组织(OU)管理 ----------------

def _list_ous(c):
    entries = do_search(c, BASE_DN, ldap.SCOPE_SUBTREE,
                        "(objectClass=organizationalUnit)", ["ou"])
    entries.sort(key=lambda e: e["dn"].lower())
    return entries


def ou_list(c):
    entries = _list_ous(c)
    if not entries:
        print("没有找到任何组织单元(OU)。")
        return
    print("\n共 %d 个组织单元:" % len(entries))
    for e in entries:
        ou = fmt_val(e["attrs"].get("ou", [b""])[0])
        print("  %-20s %s" % (ou, e["dn"]))


def ou_create(c):
    name = input("新组织名称(ou): ").strip()
    if not name:
        return
    parent = input("父容器(回车默认 %s): " % BASE_DN).strip() or BASE_DN
    dn = "ou=%s,%s" % (rdn_escape(name), parent)
    do_add(c, dn, {"objectClass": ["top", "organizationalUnit"], "ou": [name]})
    print("组织创建成功:%s" % dn)


def ou_rename(c):
    entry = pick_entry(_list_ous(c), what="组织")
    if not entry:
        return
    new_name = input("请输入新名称(ou): ").strip()
    if not new_name:
        return
    do_rename(c, entry["dn"], "ou=%s" % rdn_escape(new_name))
    print("重命名成功。")


def ou_delete(c):
    entry = pick_entry(_list_ous(c), what="组织")
    if not entry:
        return
    dn = entry["dn"]
    children = do_search(c, dn, ldap.SCOPE_ONELEVEL, "(objectClass=*)", ["cn"])
    if children:
        print("该组织下还有 %d 个子条目,不能删除。请先清空。" % len(children))
        return
    if input("确认删除组织 %s ?输入 yes 确认: " % dn).strip() != "yes":
        print("已取消。")
        return
    do_delete(c, dn)
    print("组织已删除。")


def ou_menu():
    while True:
        print("\n------ 组织(OU)管理 ------")
        print(" 1. 查看组织列表")
        print(" 2. 新建组织")
        print(" 3. 重命名组织")
        print(" 4. 删除组织")
        print(" 0. 返回上级菜单")
        choice = input("请选择: ").strip()
        try:
            if choice == "1":
                ou_list(get_client())
            elif choice == "2":
                ou_create(get_client())
            elif choice == "3":
                ou_rename(get_client())
            elif choice == "4":
                ou_delete(get_client())
            elif choice == "0":
                return
        except LdapError as exc:
            _handle_error(exc)


# ---------------- 9. 角色(组)管理 ----------------

def _list_groups(c):
    entries = do_search(c, BASE_DN, ldap.SCOPE_SUBTREE,
                        "(objectClass=%s)" % GROUP_OBJECTCLASS,
                        ["cn", "description"])
    entries.sort(key=lambda e: e["dn"].lower())
    return entries


def group_list(c):
    entries = _list_groups(c)
    if not entries:
        print("没有找到任何角色(组)。")
        return
    print("\n共 %d 个角色(组):" % len(entries))
    for e in entries:
        cn = fmt_val(e["attrs"].get("cn", [b""])[0])
        print("  %-20s %s" % (cn, e["dn"]))


def group_view_members(c):
    entry = pick_entry(_list_groups(c), what="角色")
    if not entry:
        return
    g = search_one(c, entry["dn"], ldap.SCOPE_BASE, "(objectClass=*)", [MEMBER_ATTR])
    members = [fmt_val(v) for v in g["attrs"].get(MEMBER_ATTR, [])]
    if not members:
        print("该角色暂无成员。")
        return
    print("\n角色 %s 共 %d 个成员:" % (entry["dn"], len(members)))
    for m in members:
        print("  - %s" % resolve_member_display(c, m))


def group_create(c):
    name = input("角色名称(cn): ").strip()
    if not name:
        return
    desc = input("描述(可回车跳过): ").strip()
    ou = input("存放容器(回车默认 %s): " % GROUP_OU).strip() or GROUP_OU
    dn = "cn=%s,%s" % (rdn_escape(name), ou)
    attrs = {"objectClass": GROUP_CLASSES, "cn": [name]}
    if desc:
        attrs["description"] = [desc]
    if IS_AD:
        attrs["sAMAccountName"] = [name]
        attrs["groupType"] = [AD_GROUP_TYPE]   # 全局安全组
    else:
        # OpenLDAP 的 groupOfNames 要求创建时至少有一个成员
        kw = input("初始成员登录名(回车则使用绑定账号 %s): " % BIND_DN).strip()
        if kw:
            user = pick_entry(find_user(c, kw), what="用户")
            if not user:
                print("未选择初始成员,已取消创建。")
                return
            attrs["member"] = [user["dn"]]
        else:
            attrs["member"] = [BIND_DN]
            print("[提示] 已把绑定账号设为初始成员(groupOfNames 强制要求至少一名成员),")
            print("       之后可通过【移除成员】功能删除。")
    do_add(c, dn, attrs)
    print("角色创建成功:%s" % dn)


def group_delete(c):
    entry = pick_entry(_list_groups(c), what="角色")
    if not entry:
        return
    if input("确认删除角色 %s ?输入 yes 确认: " % entry["dn"]).strip() != "yes":
        print("已取消。")
        return
    do_delete(c, entry["dn"])
    print("角色已删除。")


def group_add_member(c):
    entry = pick_entry(_list_groups(c), what="角色")
    if not entry:
        return
    user = pick_user(c, "请输入要加入的用户登录名或姓名关键字: ")
    if not user:
        return
    do_modify(c, entry["dn"], [(ldap.MOD_ADD, MEMBER_ATTR, [user["dn"]])])
    print("已把 %s 加入角色。" % user["dn"])


def group_remove_member(c):
    entry = pick_entry(_list_groups(c), what="角色")
    if not entry:
        return
    g = search_one(c, entry["dn"], ldap.SCOPE_BASE, "(objectClass=*)", [MEMBER_ATTR])
    members = [fmt_val(v) for v in g["attrs"].get(MEMBER_ATTR, [])]
    if not members:
        print("该角色暂无成员。")
        return
    print("\n当前成员:")
    for i, m in enumerate(members, 1):
        print("  %d. %s" % (i, resolve_member_display(c, m)))
    raw = input("请输入要移除的成员序号(回车取消): ").strip()
    if not raw.isdigit() or not (1 <= int(raw) <= len(members)):
        return
    target = members[int(raw) - 1]
    if input("确认把 %s 移出角色?(y/n): " % target).strip().lower() != "y":
        return
    do_modify(c, entry["dn"], [(ldap.MOD_DELETE, MEMBER_ATTR, [target])])
    print("成员已移除。")


def group_menu():
    while True:
        print("\n------ 角色(组)管理 ------")
        print(" 1. 查看角色列表")
        print(" 2. 查看角色成员")
        print(" 3. 新建角色")
        print(" 4. 删除角色")
        print(" 5. 添加成员")
        print(" 6. 移除成员")
        print(" 0. 返回上级菜单")
        choice = input("请选择: ").strip()
        try:
            if choice == "1":
                group_list(get_client())
            elif choice == "2":
                group_view_members(get_client())
            elif choice == "3":
                group_create(get_client())
            elif choice == "4":
                group_delete(get_client())
            elif choice == "5":
                group_add_member(get_client())
            elif choice == "6":
                group_remove_member(get_client())
            elif choice == "0":
                return
        except LdapError as exc:
            _handle_error(exc)


# ---------------- LDAP 结果码 -> 中文说明 ----------------
RESULT_CODES = {
    0: "成功",
    1: "服务器内部错误(operationsError)",
    2: "协议错误(protocolError)",
    3: "查询超时(timeLimitExceeded)",
    4: "结果条数超限(sizeLimitExceeded)",
    7: "服务器不支持该认证方式(authMethodNotSupported)",
    8: "需要加密连接(strongerAuthRequired)",
    10: "引用(referral)",
    32: "对象不存在(noSuchObject),请检查DN或BASE_DN是否正确",
    34: "DN语法错误(invalidDNSyntax)",
    49: "账号或密码错误(invalidCredentials)",
    50: "权限不足(insufficientAccessRights)",
    53: "服务器拒绝执行(unwillingToPerform):AD上常见原因:未加密连接改密码、密码不符合复杂度策略",
    65: "对象类违规(objectClassViolation):缺少必填属性或属性不属于对象类",
    66: "不允许删除非叶子节点(notAllowedOnNonLeaf):请先删除子节点",
    68: "条目已存在(entryAlreadyExists)",
    69: "不允许修改对象类(objectClassModsProhibited)",
    80: "未知错误(other)",
}


class LdapError(Exception):
    """LDAP 操作失败时抛出,message 已为中文描述"""

    def __init__(self, code, message=""):
        self.code = code
        desc = RESULT_CODES.get(code, "结果码 %s" % code)
        detail = (";服务器返回:%s" % message) if message else ""
        super().__init__("LDAP错误[%s] %s%s" % (code, desc, detail))


def wrap_ldap_error(exc):
    """把 python-ldap 的异常转换为中文 LdapError"""
    info = exc.args[0] if exc.args else {}
    code = info.get("result", -1)
    message = info.get("info") or info.get("desc") or str(exc)
    return LdapError(code, message)


# ======================================================================
#                        小工具函数
# ======================================================================

def to_bytes(value):
    """python-ldap 要求属性值为 bytes,str 统一按 UTF-8 编码"""
    if isinstance(value, bytes):
        return value
    return str(value).encode("utf-8")


def to_bytes_list(values):
    return [to_bytes(v) for v in values]


def rdn_escape(s):
    """转义 DN 中 RDN 值的特殊字符(构造新条目DN时使用)"""
    s = (s.replace("\\", "\\\\")
          .replace(",", "\\,")
          .replace("+", "\\+")
          .replace('"', '\\"')
          .replace("<", "\\<")
          .replace(">", "\\>")
          .replace(";", "\\;")
          .replace("=", "\\="))
    if s.startswith(" ") or s.startswith("#"):
        s = "\\" + s
    if s.endswith(" "):
        s = s[:-1] + "\\ "
    return s


def fmt_val(raw):
    """把属性值(bytes)格式化为可显示字符串;二进制内容显示为十六进制"""
    if isinstance(raw, str):
        return raw
    try:
        text = raw.decode("utf-8")
        if any(ord(c) < 32 and c != "\t" for c in text):
            raise UnicodeDecodeError("utf-8", raw, 0, 1, "控制字符")
        return text
    except UnicodeDecodeError:
        return "0x" + raw.hex()


def _filter_escape(s):
    """转义搜索过滤器中的特殊字符(RFC4515)"""
    return (s.replace("\\", r"\5c")
             .replace("*", r"\2a")
             .replace("(", r"\28")
             .replace(")", r"\29"))


# ======================================================================
#                            主菜单
# ======================================================================

def _handle_error(exc):
    """统一错误处理;连接类错误重置连接,下次操作自动重连"""
    if isinstance(exc, LdapError) and exc.code in (-1, 2, 80, 81, 82):
        # 81=无法连接服务器 82=本地错误/连接断开,重置后下次操作自动重连
        reset_client()
    print("[失败] %s" % exc)


def pause():
    input("\n按回车键返回菜单...")


def main():
    print("=" * 52)
    print("       LDAP 客户端测试工具(基于 python-ldap)")
    print("=" * 52)
    menu_actions = {
        "1": ("连接与认证测试", op_test_connection),
        "2": ("查询用户", op_search_user),
        "3": ("新建用户", op_add_user),
        "4": ("修改用户", op_modify_user),
        "5": ("删除用户", op_delete_user),
        "6": ("重置用户密码", op_reset_password),
        "7": ("启用/禁用账号(仅AD)", op_toggle_account),
        "8": ("组织(OU)管理", ou_menu),
        "9": ("角色(组)管理", group_menu),
    }
    while True:
        status = "已连接" if _client is not None else "未连接"
        enc = "LDAPS" if USE_SSL else ("StartTLS" if USE_STARTTLS else "明文")
        print("\n服务器: %s:%d (%s)  类型: %s  状态: %s" %
              (LDAP_SERVER, LDAP_PORT, enc, SERVER_TYPE, status))
        print("-" * 52)
        for key in sorted(menu_actions):
            print(" %s. %s" % (key, menu_actions[key][0]))
        print(" 0. 退出")
        choice = input("请选择: ").strip()
        if choice == "0":
            break
        action = menu_actions.get(choice)
        if not action:
            print("无效的选择。")
            continue
        try:
            action[1]()
        except LdapError as exc:
            _handle_error(exc)
        except ldap.LDAPError as exc:
            _handle_error(wrap_ldap_error(exc))
        except KeyboardInterrupt:
            print("\n操作已取消。")
            continue
        if choice not in ("8", "9"):
            pause()
    reset_client()
    print("再见!")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        reset_client()
        print("\n再见!")

    
最近修改: 2026-10-22Powered by