LDAP修改密码

1、免证书修改密码配置

spring:
  mvc:
    hiddenmethod:
      filter:
        enabled: true #开启之后才能使用Rest的delete、put
    contentnegotiation:
      favor-parameter: true #开启请求参数的内容协商功能
  http:
    client:
      connect-timeout: 60s
      read-timeout: 120s
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB
  ldap:
    urls: ldaps://192.168.1.3:636
    base: DC=xxad,DC=com
    username: admin@xxad.com
    password: xxXX2026
package com.stc.cert.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.core.support.LdapContextSource;

import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.HashMap;
import java.util.Map;

/**
 * @Auther: stc66@qq.com
 * @Date: 2026-04-02
 * @Description: LDAP免证书配置
 * @Version: 1.0
 */
@Configuration
public class LdapConfig {

    @Value("${spring.ldap.urls}")
    String urls;

    @Value("${spring.ldap.base}")
    String base;

    @Value("${spring.ldap.username}")
    String username;

    @Value("${spring.ldap.password}")
    String password;

    @Bean
    public LdapContextSource ldapContextSource() throws Exception {
        // 1. 信任所有证书
        SSLContext ctx = SSLContext.getInstance("TLS");
        ctx.init(null, new TrustManager[]{new X509TrustManager() {
            public void checkClientTrusted(X509Certificate[] chain, String authType) {
            }

            public void checkServerTrusted(X509Certificate[] chain, String authType) {
            }

            public X509Certificate[] getAcceptedIssuers() {
                return new X509Certificate[0];
            }
        }}, new SecureRandom());
        // 关键:设置为 JVM 默认,JNDI 会自动使用
        SSLContext.setDefault(ctx);

        // 2. 禁用主机名验证(解决 IP 地址 SAN 不匹配问题)
        HttpsURLConnection.setDefaultSSLSocketFactory(ctx.getSocketFactory());
        HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);

        // 3. 禁用 JNDI 的端点身份验证(LDAPS 必须加这行)
        System.setProperty("com.sun.jndi.ldap.object.disableEndpointIdentification", "true");

        LdapContextSource contextSource = new LdapContextSource();
        contextSource.setUrl(urls);
        contextSource.setBase(base);
        contextSource.setUserDn(username);
        contextSource.setPassword(password);
        contextSource.setPooled(false); // 关闭连接池,避免类加载器问题


        Map<String, Object> env = new HashMap<>();
        // 连接超时(毫秒)
        env.put("com.sun.jndi.ldap.connect.timeout", "5000");
        // 读取超时(毫秒)
        env.put("com.sun.jndi.ldap.read.timeout", "10000");
        contextSource.setBaseEnvironmentProperties(env);

        return contextSource;
    }

    @Bean
    public LdapTemplate ldapTemplate() throws Exception {
        LdapTemplate ldapTemplate = new LdapTemplate(ldapContextSource());
        // 忽略部分 LDAP 错误,避免因连接问题抛出异常
        ldapTemplate.setIgnorePartialResultException(true);
        ldapTemplate.setIgnoreNameNotFoundException(true);
        return ldapTemplate;
    }
}

2、实体及数据绑定

package com.stc.cert.entity;

import lombok.Data;
import org.springframework.ldap.odm.annotations.Attribute;
import org.springframework.ldap.odm.annotations.Entry;
import org.springframework.ldap.odm.annotations.Id;

import javax.naming.Name;
import java.util.List;

/**
 * @Auther: stc66@qq.com
 * @Date: 2026-04-02
 * @Description: com.szbxl.cert.entity
 * @Version: 1.0
 */
@Data
@Entry(objectClasses = {"user", "organizationalPerson", "person", "top"})
public class AdUser {

    @Id
    private Name dn;

    @Attribute(name = "sAMAccountName")
    private String username;          // 登录名

    @Attribute(name = "cn")
    private String commonName;        // 全名

    @Attribute(name = "displayName")
    private String displayName;       // 显示名

    @Attribute(name = "mail")
    private String email;             // 邮箱

    @Attribute(name = "mobile")
    private String mobile;            // 手机号

    @Attribute(name = "department")
    private String department;        // 部门

    @Attribute(name = "title")
    private String title;             // 职位

    @Attribute(name = "telephoneNumber")
    private String telephone;         // 电话

    @Attribute(name = "userAccountControl")
    private String userAccountControl; // 账号状态

    @Attribute(name = "memberOf")
    private List<String> memberOf;    // 所属组

    /**
     * 判断账号是否启用
     * userAccountControl & 2 == 2 表示账号被禁用
     */
    public boolean isEnabled() {
        if (userAccountControl == null) return true;
        int uac = Integer.parseInt(userAccountControl);
        return (uac & 2) == 0;
    }
}
package com.stc.cert.repository;

import com.stc.cert.entity.AdUser;
import org.springframework.data.ldap.repository.LdapRepository;

import java.util.List;

/**
 * @Auther: stc66@qq.com
 * @Date: 2026-04-02
 * @Description: com.szbxl.cert.services
 * @Version: 1.0
 */
public interface AdUserRepository extends LdapRepository<AdUser> {

    // 按用户名查询
    AdUser findByUsername(String username);

    // 按邮箱查询
    List<AdUser> findByEmail(String email);

    // 按部门查询
    List<AdUser> findByDepartment(String department);
}

3、服务及控制器

package com.stc.cert.service;

import com.stc.cert.entity.AdUser;
import com.stc.cert.repository.AdUserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ldap.AuthenticationException;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.filter.AndFilter;
import org.springframework.ldap.filter.EqualsFilter;
import org.springframework.ldap.filter.LikeFilter;
import org.springframework.ldap.filter.OrFilter;
import org.springframework.ldap.query.LdapQuery;
import org.springframework.ldap.query.LdapQueryBuilder;
import org.springframework.ldap.query.SearchScope;
import org.springframework.stereotype.Service;

import javax.naming.directory.BasicAttribute;
import javax.naming.directory.DirContext;
import javax.naming.directory.ModificationItem;
import java.io.UnsupportedEncodingException;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

/**
 * @Auther: stc66@qq.com
 * @Date: 2026-04-02
 * @Description: com.stc.cert.services
 * @Version: 1.0
 */
@Slf4j
@Service
public class AdUserService {

    private final LdapTemplate ldapTemplate;

    private final AdUserRepository adUserRepository;

    public AdUserService(LdapTemplate ldapTemplate, AdUserRepository adUserRepository) {
        this.ldapTemplate = ldapTemplate;
        this.adUserRepository = adUserRepository;
    }

    /**
     * 按用户名查询单个用户
     */
    public AdUser findByUsername(String username) {
        LdapQuery query = LdapQueryBuilder.query()
                .searchScope(SearchScope.SUBTREE)
                .where("objectClass").is("user")
                .and("sAMAccountName").is(username);

        List<AdUser> users = ldapTemplate.find(query, AdUser.class);
        return users.isEmpty() ? null : users.get(0);
    }

    /**
     * 按部门查询用户列表
     */
    public List<AdUser> findByDepartment(String department) {
        LdapQuery query = LdapQueryBuilder.query()
                .searchScope(SearchScope.SUBTREE)
                .where("objectClass").is("user")
                .and("department").is(department)
                .and("userAccountControl").not().is("514"); // 排除禁用账号

        return ldapTemplate.find(query, AdUser.class);
    }

    /**
     * 模糊搜索用户(按姓名或用户名模糊搜索或手机号码精准搜索)
     */
    public List<AdUser> searchUsers(String keyword) {
        AndFilter filter = new AndFilter();
        filter.and(new EqualsFilter("objectClass", "user"));
        // 过滤掉禁用账户(userAccountControl 第2位为1表示禁用)
        filter.and(new NotFilter(new EqualsFilter("userAccountControl:1.2.840.113556.1.4.803:", "2")));

        OrFilter orFilter = new OrFilter();
        orFilter.or(new LikeFilter("sAMAccountName", "*" + keyword + "*"));
        orFilter.or(new LikeFilter("displayName", "*" + keyword + "*"));
        orFilter.or(new LikeFilter("mail", "*" + keyword + "*"));
        orFilter.or(new LikeFilter("mobile", keyword));

        filter.and(orFilter);

        return ldapTemplate.find(
                LdapQueryBuilder.query()
                        .searchScope(SearchScope.SUBTREE)
                        .filter(filter),
                AdUser.class
        );
    }

    /**
     * 查询某个 OU 下的所有用户
     */
    public List<AdUser> findUsersByOu(String ouPath) {
        LdapQuery query = LdapQueryBuilder.query()
                .base(ouPath)  // 例如: "OU=研发部,OU=员工,DC=example,DC=com"
                .searchScope(SearchScope.SUBTREE)
                .where("objectClass").is("user")
                .and("objectClass").not().is("computer");

        return ldapTemplate.find(query, AdUser.class);
    }

    /**
     * 查询用户所属的所有组
     */
    public List<String> getUserGroups(String username) {
        AdUser user = findByUsername(username);
        if (user == null || user.getMemberOf() == null) {
            return Collections.emptyList();
        }

        // 从 DN 中提取组名,例如 CN=开发组,OU=Groups,DC=example,DC=com -> 开发组
        return user.getMemberOf().stream()
                .map(dn -> {
                    String[] parts = dn.split(",");
                    return parts[0].replace("CN=", "").trim();
                })
                .collect(Collectors.toList());
    }

    /**
     * 查询所有启用的用户
     */
    public List<AdUser> findAllEnabledUsers() {
        // userAccountControl 正常启用账号值为 512
        LdapQuery query = LdapQueryBuilder.query()
                .searchScope(SearchScope.SUBTREE)
                .where("objectClass").is("user")
                .and("objectClass").not().is("computer")
                .and("userAccountControl").is("512");

        return ldapTemplate.find(query, AdUser.class);
    }

    /**
     * 修改LDAP用户密码
     *
     * @param username    用户名(通常是uid或cn)
     * @param oldPassword 旧密码
     * @param newPassword 新密码
     * @return 修改结果
     */
    public boolean changePassword(String username, String oldPassword, String newPassword) {
        boolean flag;
        // 1. 验证新密码复杂度
        if (!isValidPassword(newPassword)) {
            throw new IllegalArgumentException("密码必须长度大于6位,包含大小写字母和数字");
        }

        // 2. 验证旧密码是否正确
        if (!authenticate(username, oldPassword)) {
            throw new SecurityException("旧密码验证失败");
        }

        // 3. 执行密码修改
        // 1. 先查出用户的完整 DN
        AdUser user = findByUsername(username);
        if (user == null) {
            throw new RuntimeException("用户不存在: " + username);
        }
        String userDn = user.getDn().toString();

        // 2. AD 密码字段是 unicodePwd,必须用 UTF-16LE 编码并加双引号
        ModificationItem[] mods = buildPasswordMods(oldPassword, newPassword);

        try {
            ldapTemplate.modifyAttributes(userDn, mods);
            log.info("用户 {} 密码修改成功", username);
            flag = true;
        } catch (org.springframework.ldap.AuthenticationException e) {
            log.warn("用户 {} 旧密码验证失败", username);
            throw new RuntimeException("旧密码不正确");
        } catch (org.springframework.ldap.OperationNotSupportedException e) {
            log.error("密码修改不支持,请确认使用 LDAPS 连接: {}", e.getMessage());
            throw new RuntimeException("密码修改失败,请确认使用 LDAPS 连接");
        } catch (Exception e) {
            log.error("用户 {} 密码修改失败: {}", username, e.getMessage());
            throw new RuntimeException("密码修改失败: " + parseAdError(e.getMessage()));
        }
        return flag;
    }

    /**
     * 管理员强制重置密码(不需要旧密码,绑定账号需有 Reset Password 权限)
     *
     * @param username    用户的 sAMAccountName
     * @param newPassword 新密码
     */
    public boolean resetPassword(String username, String newPassword) {
        boolean falg;
        AdUser user = findByUsername(username);
        if (user == null) {
            throw new RuntimeException("用户不存在: " + username);
        }
        String userDn = user.getDn().toString();

        // 强制重置只需要 REPLACE 操作,不需要旧密码
        ModificationItem[] mods = new ModificationItem[]{
                new ModificationItem(
                        DirContext.REPLACE_ATTRIBUTE,
                        new BasicAttribute("unicodePwd", encodePassword(newPassword))
                )
        };

        try {
            ldapTemplate.modifyAttributes(userDn, mods);
            falg = true;
            log.info("用户 {} 密码重置成功", username);
        } catch (Exception e) {
            log.error("用户 {} 密码重置失败: {}", username, e.getMessage());
            throw new RuntimeException("密码重置失败: " + parseAdError(e.getMessage()));
        }
        return falg;
    }

    /**
     * 验证用户密码(AD 认证)
     */
    public boolean authenticate(String username, String password) {
        try {
            LdapQuery query = LdapQueryBuilder.query()
                    .searchScope(SearchScope.SUBTREE)
                    .where("objectClass").is("user")
                    .and("sAMAccountName").is(username);

            ldapTemplate.authenticate(query, password);
            return true;
        } catch (AuthenticationException e) {
            // 解析 AD 具体错误码
            String msg = e.getMessage();
            if (msg != null) {
                if (msg.contains("52e")) log.warn("用户 {} 密码错误", username);
                else if (msg.contains("533")) log.warn("用户 {} 账号已禁用", username);
                else if (msg.contains("775")) log.warn("用户 {} 账号已锁定", username);
                else if (msg.contains("532")) log.warn("用户 {} 密码已过期", username);
                else log.warn("用户 {} 认证失败: {}", username, msg);
            }
            return false;
        } catch (Exception e) {
            log.error("AD 认证异常,用户名: {}", username, e);
            return false;
        }
    }

    /**
     * 验证密码是否包含大小写字母和数字
     *
     * @param password 待验证的密码
     * @return true表示符合要求,false表示不符合
     */
    public boolean isValidPassword(String password) {
        if (password == null || password.length() < 6) {
            return false;
        }

        boolean hasUppercase = false;
        boolean hasLowercase = false;
        boolean hasDigit = false;

        for (char c : password.toCharArray()) {
            if (Character.isUpperCase(c)) {
                hasUppercase = true;
            } else if (Character.isLowerCase(c)) {
                hasLowercase = true;
            } else if (Character.isDigit(c)) {
                hasDigit = true;
            }
        }
        return hasUppercase && hasLowercase && hasDigit;
    }


    /**
     * 构建修改密码的 ModificationItem 数组
     * 普通用户改密码需要先 DELETE 旧密码,再 ADD 新密码
     */
    private ModificationItem[] buildPasswordMods(String oldPassword, String newPassword) {
        if (oldPassword != null) {
            // 用户自己改密码:DELETE 旧密码 + ADD 新密码
            return new ModificationItem[]{
                    new ModificationItem(
                            DirContext.REMOVE_ATTRIBUTE,
                            new BasicAttribute("unicodePwd", encodePassword(oldPassword))
                    ),
                    new ModificationItem(
                            DirContext.ADD_ATTRIBUTE,
                            new BasicAttribute("unicodePwd", encodePassword(newPassword))
                    )
            };
        } else {
            // 管理员重置:直接 REPLACE
            return new ModificationItem[]{
                    new ModificationItem(
                            DirContext.REPLACE_ATTRIBUTE,
                            new BasicAttribute("unicodePwd", encodePassword(newPassword))
                    )
            };
        }
    }

    /**
     * AD 密码编码:UTF-16LE + 双引号包裹
     * AD 要求 unicodePwd 字段的格式为 "password" 的 UTF-16LE 字节数组
     */
    private byte[] encodePassword(String password) {
        String quotedPassword = "\"" + password + "\"";
        try {
            return quotedPassword.getBytes("UTF-16LE");
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException("密码编码失败", e);
        }
    }

    /**
     * 解析 AD 错误码,返回友好提示
     */
    private String parseAdError(String message) {
        if (message == null) return "未知错误";
        if (message.contains("52e")) return "旧密码不正确";
        if (message.contains("523")) return "新密码不符合密码策略";
        if (message.contains("524")) return "不能使用最近用过的密码";
        if (message.contains("532")) return "密码已过期";
        if (message.contains("533")) return "账号已禁用";
        if (message.contains("775")) return "账号已锁定";
        if (message.contains("constraint")) return "新密码不满足复杂度要求";
        return message;
    }
}
package com.stc.cert.controller;

import com.stc.cert.dto.AdminResetDTO;
import com.stc.cert.dto.PasswordChangeDTO;
import com.stc.cert.entity.AdUser;
import com.stc.cert.service.AdUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

/**
 * @Auther: stc66@qq.com
 * @Date: 2026-04-02
 * @Description: com.stc.cert.controller
 * @Version: 1.0
 */
@RestController
@RequestMapping("/back/ad")
public class AdUserController {
    @Autowired
    private AdUserService adUserService;

    @PostMapping("/change")
    public ResponseEntity<?> changePassword(@RequestBody PasswordChangeDTO passwordChange) {
        try {
            boolean result = adUserService.changePassword(passwordChange.getUsername(), passwordChange.getOldPassword(), passwordChange.getNewPassword());
            return ResponseEntity.ok(Map.of("msg", "密码修改成功", "data", result));
        } catch (IllegalArgumentException e) {
            return ResponseEntity.badRequest().body(Map.of("msg", e.getMessage()));
        } catch (SecurityException e) {
            return ResponseEntity.status(401).body(Map.of("msg", e.getMessage()));
        } catch (Exception e) {
            return ResponseEntity.internalServerError().body(Map.of("msg", e.getMessage()));
        }
    }

    @PostMapping("/admin/reset")
    public ResponseEntity<?> adminResetPassword(@RequestBody AdminResetDTO adminResetDTO) {
        try {
            boolean result = adUserService.resetPassword(adminResetDTO.getUsername(), adminResetDTO.getNewPassword());
            return ResponseEntity.ok(Map.of("msg", "密码重置成功", "data", result));
        } catch (IllegalArgumentException e) {
            return ResponseEntity.badRequest().body(Map.of("msg", e.getMessage()));
        } catch (Exception e) {
            return ResponseEntity.internalServerError().body(Map.of("msg", e.getMessage()));
        }
    }

    // 按用户名查询
    @GetMapping("/user/{username}")
    public ResponseEntity<?> getUser(@PathVariable String username) {
        AdUser user = adUserService.findByUsername(username);
        return user != null
                ? ResponseEntity.ok(Map.of("msg", "查询成功", "data", user))
                : ResponseEntity.badRequest().body(Map.of("msg", "未查询到用户"));
    }

    // 模糊搜索
    @GetMapping("/users/search")
    public ResponseEntity<?> searchUsers(@RequestParam String keyword) {
        return ResponseEntity.ok(Map.of("msg", "搜索成功", "data", adUserService.searchUsers(keyword)));
    }

    // 按部门查询
    @GetMapping("/users/department")
    public ResponseEntity<?> getUsersByDepartment(@RequestParam String department) {
        return ResponseEntity.ok(Map.of("data", adUserService.findByDepartment(department)));
    }

    // AD 认证
/*    @PostMapping("/auth")
    public ResultData<Boolean> authenticate(@RequestBody Map<String, String> body) {
        boolean result = adUserService.authenticate(body.get("username"), body.get("password"));
        return ResultData.success(result);
    }*/
}

 

posted @ 2026-04-07 15:33  滔天蟹  阅读(1)  评论(0)    收藏  举报