/**
* 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);
}
}
}
}
#!/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再见!")