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);
}*/
}