SpringSecurity + Spnego + Kerberos 实现AD域单点登录
文章目录
- SpringSecurity + Spnego + Kerberos 实现AD域单点登录
- 前言
- 一、域是什么?
- 二、单点登录是什么?
- 三、如何实现
- 四、Kerberos
- 五、Spnego 介绍
- 六、spring-ldap 连接域实现域用户管理
- 七、单点登录案例
前言
本文主要介绍了域的基本概念,Kerberos 的基本概念。以及如何结合实现域单点登录及域用户的同步管理。
一、域是什么?
域,英文domain,是计算机专用词汇,是指Windows网络中独立运行的单位。
实际上我们可以把域和工作组联系起来理解,在工作组上你一切的设置在本机上进行包括各种策略,用户登录也是登录在本机的,密码是放在本机的数据库来验证的。而如果你的计算机加入域的话,各种策略是域控制器统一设定,用户名和密码也是放到域控制器去验证,也就是说你的账号密码可以在同一域的任何一台计算机登录。
常见于大型企业的计算机安全管理。因此部分软件开发时要求接入域管理。用户在登录自己电脑后,可直接打开对应系统,无需输入软件的账号密码可直接登录。
如何安装部署自己的域可参考:Windows Server 2016域服务器部署
二、单点登录是什么?
单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
在本场景下,用户打开电脑输入域账户登录到桌面,即可直接访问互信的系统,无需更多的操作。
三、如何实现
Spring Security 提供了Kerberos 认证的扩展。可通过Kerberos 协议进行域服务与系统之间的互信,从而实现单点登录。
注:本案例基于Spring Security 5.1.5.RELEASE 和 JDK 8 版本编写,故参考文档为 spring-security-kerberos的1.0.1.RELEASE版本。
当前最新的 Spring Security Kerberos 2.1.1 需要基于 JDK 17、 Spring Security 6.3.0 和 Spring Framework 6.1.8。可参考官方最新文档
四、Kerberos
Kerberos是一种计算机网络授权协议,用来在非安全网络中,对个人通信以安全的手段进行身份认证。这个词又指麻省理工学院为这个协议开发的一套计算机软件。
对于Kerberos 的概念介绍已经有很多,这里不再赘述,推荐观看下面几位大佬的几篇博客
Windows安全认证机制——Kerberos 域认证
kerberos认证原理—讲的非常细致,易懂
Kerberos原理–经典对话
五、Spnego 介绍
SPNEGO(Simple and Protected GSS-API Negotiation)是一种由微软提供的安全协议,它使用GSS-API(通用安全服务应用接口)认证机制,旨在使Web服务器能够共享Windows凭据,并扩展了Kerberos协议,这是一种网络认证协议。
SPNEGO协议的主要功能是在客户端和Web服务器之间协商一个通信协议,并定义了一种方式,通过这种方式,客户端可以发送可验证的证明数据给Web服务器。如果Web服务器能够解析客户端发送的SPNEGO令牌,那么这个令牌将用于实现安全单点登录,从而以安全的方式协商用户的身份。
SPNEGO协议的工作流程大致如下:客户端根据用户名向密钥分发中心(KDC)的身份认证服务(AS)请求票据授予服务(TGS)票证。AS生成TGS票证,查询用户的密码,并用用户的密码将TGS票证加密后返回给客户端。客户端使用用户的密码解密TGS票证,如果密码正确,就能获取TGS票证,然后使用该票证向TGS服务请求服务票证。TGS服务生成服务票证并响应给客户端。客户端将服务票证封装到SPNEGO令牌中,并发送给Web服务器。服务器解密出用户名及服务票证,将票证发送到TGS服务进行验证。如果验证通过,通信开始。
SPNEGO协议支持基于Kerberos或NTLM凭据的Active Directory凭据。不同的实现方案可能支持不同的认证方式,例如IBM TAI方案只支持基于Kerberos凭据的认证,而Tivoli Access Manager支持NTLM和Kerberos两种凭据。
六、spring-ldap 连接域实现域用户管理
通过 spring-security-ldap 提供的类库可以轻松实现与域服务器用户的连接与同步。
- 添加配置信息
spring.ldap.urls=ldap://testldap.com:389
spring.ldap.base=CN=Users,DC=testldap,DC=com
spring.ldap.username=CN=Administrator,CN=Users,DC=testldap,DC=com
spring.ldap.password=test
- 编写自动配置类
/**
1. LDAP 的自动配置类
2. 该类用于配置和初始化LDAP连接,以便在应用程序中使用LDAP服务。
*/
@Configuration
public class LdapConfiguration {
// 日志记录器
private static Logger logger = LoggerFactory.getLogger(LdapConfiguration.class);
// LDAP 模板对象,用于执行LDAP操作
private LdapTemplate ldapTemplate;
// 从配置文件中读取LDAP服务器的URL
@Value("${spring.ldap.urls}")
private String dbUrl;
// 从配置文件中读取LDAP服务器的用户名
@Value("${spring.ldap.username}")
private String username;
// 从配置文件中读取LDAP服务器的密码
@Value("${spring.ldap.password}")
private String password;
// 从配置文件中读取LDAP服务器的基础DN
@Value("${spring.ldap.base}")
private String base;
/**
* 创建并配置LdapContextSource对象
* 该对象用于建立与LDAP服务器的连接
*
* @return 配置好的LdapContextSource对象
*/
@Bean
public LdapContextSource contextSource() {
LdapContextSource contextSource = new LdapContextSource();
Map<String, Object> config = new HashMap<>();
contextSource.setUrl(dbUrl);
contextSource.setBase(base);
contextSource.setUserDn(username);
contextSource.setPassword(password);
// 解决乱码的关键配置
config.put("java.naming.ldap.attributes.binary", "objectGUID");
contextSource.setPooled(true);
contextSource.setBaseEnvironmentProperties(config);
return contextSource;
}
/**
* 创建并配置LdapTemplate对象
* 该对象用于执行LDAP操作
*
* @param contextSource 已配置的LdapContextSource对象
* @return 配置好的LdapTemplate对象
*/
@Bean
public LdapTemplate ldapTemplate(LdapContextSource contextSource) {
if (Objects.isNull(contextSource)) {
throw new RuntimeException("ldap contextSource error");
}
if (null == ldapTemplate) {
ldapTemplate = new LdapTemplate(contextSource);
}
return ldapTemplate;
}
}
- 编写定时任务定时同步用户信息
public void syncUserInfo() {
// 记录同步用户信息任务开始的日志
logger.info("LdapSyncTask syncUserInfo start");
// 创建一个过滤器,用于筛选出对象类为"person"的条目
AndFilter filter = new AndFilter();
filter.and(new EqualsFilter("objectClass", "person"));
// 从LDAP中搜索符合条件的用户信息,并使用LdapUserAttributeMapper将结果映射为LdapUser对象列表
List<LdapUser> users = ldapTemplate.search("", filter.encode(), new LdapUserAttributeMapper());
// 如果没有找到任何用户,记录日志并返回
if (users == null || users.isEmpty()) {
logger.info("LdapSyncTask syncUserInfo users is null");
return;
}
// 查询系统中的用户列表
List<Map<String, Object>> sysUserList = iLdapSyncDao.queryFactoryUserList();
// 将系统中启用的用户(useflag为1)转换为Map,键为用户代码,值为用户名
Map<String, String> userMap = sysUserList.stream()
.filter(map -> "1".equals(StringUtils.getStringFromMap(map, "useflag")))
.collect(Collectors.toMap(map -> StringUtils.getStringFromMap(map, "code"), map -> StringUtils.getStringFromMap(map, "name")));
// 将系统中禁用的用户(useflag为0)转换为Map,键为用户代码,值为用户名
Map<String, String> delUserMap = sysUserList.stream()
.filter(map -> "0".equals(StringUtils.getStringFromMap(map, "useflag")))
.collect(Collectors.toMap(map -> StringUtils.getStringFromMap(map, "code"), map -> StringUtils.getStringFromMap(map, "name")));
// 加密默认密码
String password = BambooPasswordEncoder.encrypt(defaultPasswd);
// 为每个用户设置加密后的密码和工厂ID,并记录日志
users.forEach(user -> {
user.setPassword(password);
user.setFactoryId(factoryId);
logger.info("LdapSyncTask syncUserInfo user:{}", user);
});
// 将用户按SAMAccountName分组
Map<String, List<LdapUser>> newUserMap = users.stream()
.collect(Collectors.groupingBy(LdapUser::getSAMAccountName));
// 初始化插入和更新的用户列表
List<LdapUser> insertUserList = new ArrayList<>();
List<LdapUser> updateUserList = new ArrayList<>();
// 遍历分组后的用户列表
for (Map.Entry<String, List<LdapUser>> entry : newUserMap.entrySet()) {
String code = entry.getKey();
// 如果用户在禁用用户列表中,则将其加入更新列表
if (delUserMap.containsKey(code)) {
updateUserList.addAll(entry.getValue());
}
// 如果用户不在系统用户列表中,则将其加入插入列表
else if (!userMap.containsKey(code)) {
insertUserList.addAll(entry.getValue());
}
}
// 如果有需要插入的用户,调用DAO方法插入用户信息
if (!insertUserList.isEmpty()) {
iLdapSyncDao.insertUserInfo(insertUserList);
} else {
logger.info("LdapSyncTask syncUserInfo insertUserList isEmpty");
}
// 如果有需要更新的用户,调用DAO方法更新用户信息
if (!updateUserList.isEmpty()) {
iLdapSyncDao.updateUserInfo(updateUserList);
} else {
logger.info("LdapSyncTask syncUserInfo updateUserList isEmpty");
}
// 记录同步用户信息任务结束的日志
logger.info("LdapSyncTask syncUserInfo end");
}
七、单点登录案例
本案例展示了通过一个spring服务进行鉴权中转,令域服务与原有系统之间进行对接,实现单点登录的过程
- 首先将系统所部署的服务器(下文称系统服务器)加入到域中
- 在域服务器上生成系统服务器账户密钥表
ktpass /out c:\admin1.keytab /mapuser [email protected] /princ HTTP/Admin1.test.com:[email protected] /pass Shzy@2024 /ptype KRB5_NT_PRINCIPAL /crypto All
ktpass命令(Kerberos Key Distribution Center Passwd)是Windows Server上的一个命令行工具,用于创建和管理Kerberos密钥表(Keytab)
out: 文件输出路径
mapuser: 将主体(上述)映射到此用户帐户(默认:不映射)
princ: 主体名称(user@REALM)即服务地址 通常格式为 service/account@REALM
pass: 要使用的密码
ptype: 所涉及的主体类型
ptype: KRB5_NT_PRINCIPAL:常规主体类型--推荐
ptype: KRB5_NT_SRV_INST:用户服务实例
ptype: KRB5_NT_SRV_HST:主机服务实例
crypt: 指定要使用的加密类型
- 编写krb5.conf 和login.conf 并作为启动参数加入
# krb5.conf
# [libdefaults] 部分定义了默认的配置选项
[libdefaults]
# 设置默认的realm
default_realm = TEST.COM
# 是否通过DNS查找realm,默认为false
dns_lookup_realm = true
# 是否通过DNS查找KDC,默认为true
dns_lookup_kdc = true
# 票据的有效时间
ticket_lifetime = 24h
# 票据的续订时间
renew_lifetime = 7d
# 是否允许票据可转发
forwardable = true
default_keytab_name = D:\\test.com\\krb5.keytab\\admin1.keytab
# [realms] 部分定义了realm的具体配置
[realms]
# 设置KDC的位置
TEST.COM = {
# KDC服务器的地址
kdc = ADServer.test.com
# KDC管理员服务器的地址
admin_server = ADServer.test.com
}
# [domain_realm] 部分定义了域名到realm的映射
[domain_realm]
# 映射域名到realm
.test.com = TEST.COM
test.com = TEST.COM
# login.conf
spnego-client {
com.sun.security.auth.module.Krb5LoginModule required;
};
spnego-server {
com.sun.security.auth.module.Krb5LoginModule required
isInitiator=false
storeKey=true
useKeyTab=true
debug=true
};
启动参数
-Djava.security.krb5.conf=classpath:krb5.conf -Djava.security.auth.login.config=classpath:login.conf -Dsun.security.krb5.debug=true -Dsun.security.spnego.debug=true -Dsun.security.krb5.rcache=none
- 配置SPNEGO认证相关的安全措施
@Configuration
@EnableWebMvcSecurity
public class SpnegoConfig extends WebSecurityConfigurerAdapter {
@Value("${app.service-principal}")
private String servicePrincipal; // 服务主体名称
@Value("${app.keytab-location}")
private String keytabLocation; // keytab文件位置
@Value("${app.mes.login}")
private String mesUrl; // MES登录URL
/**
* 配置HTTP安全策略。
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable(); // 禁用CSRF保护
http.formLogin().disable(); // 禁用表单登录
http
.exceptionHandling()
.authenticationEntryPoint(spnegoEntryPoint())// 设置入口点
.and()
.authorizeRequests()
.antMatchers("/", "/login", "/logout").permitAll()// 允许访问特定路径
.anyRequest().authenticated()// 所有其他请求都需要认证
.and()
.formLogin() // 表单登录配置
.and()
.logout()
.logoutUrl("/home")// 注销URL
// .logoutSuccessUrl("/index")
.invalidateHttpSession(true) // 使会话无效
.deleteCookies("JSESSIONID")// 删除指定的cookie
.clearAuthentication(true)// 清除认证信息
.permitAll() // 允许所有用户访问注销端点
.and()
.addFilterBefore(
spnegoAuthenticationProcessingFilter(authenticationManagerBean()),// 添加过滤器
BasicAuthenticationFilter.class); // 在Basic认证过滤器之前
// http.addFilterBefore(spnegoLogoutFilter(), LogoutFilter.class);
}
/**
* 创建一个LogoutFilter实例。
*/
@Bean
public LogoutFilter spnegoLogoutFilter() {
// 自定义注销过滤器
LogoutFilter logoutFilter = new LogoutFilter("/home", new SecurityContextLogoutHandler() {
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
// 设置响应头并重定向到首页
// String user = response.getHeader("WWW-Authenticate");
response.addHeader("WWW-Authenticate", "Negotiate");
response.setStatus(401);
RequestDispatcher dispatcher = request.getRequestDispatcher("/home");
try {
dispatcher.forward(request, response);
response.sendRedirect("/home");
} catch (IOException e) {
throw new RuntimeException(e);
} catch (ServletException e) {
throw new RuntimeException(e);
}
super.logout(request, response, authentication);
logger.info("SPNEGO logout handler invoked.");
}
});
return logoutFilter;
}
/**
* 创建一个AuthenticationManager实例。
*/
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
List<AuthenticationProvider> providers = new ArrayList<>();
providers.add(kerberosAuthenticationProvider());
providers.add(kerberosServiceAuthenticationProvider());
return new ProviderManager(providers);
}
/**
* 创建一个KerberosAuthenticationProvider实例。
*/
@Bean
public KerberosAuthenticationProvider kerberosAuthenticationProvider() {
KerberosAuthenticationProvider provider =
new KerberosAuthenticationProvider();
SunJaasKerberosClient client = new SunJaasKerberosClient();
client.setDebug(true);
provider.setKerberosClient(client);
provider.setUserDetailsService(dummyUserDetailsService());
return provider;
}
/**
* 创建一个SpnegoEntryPoint实例。
*/
@Bean
public SpnegoEntryPoint spnegoEntryPoint() {
return new SpnegoEntryPoint("/login");
}
/**
* 创建一个SpnegoAuthenticationProcessingFilter实例。
*/
@Bean
public SpnegoAuthenticationProcessingFilter spnegoAuthenticationProcessingFilter(
AuthenticationManager authenticationManager) {
SpnegoAuthenticationProcessingFilter filter =
new SpnegoAuthenticationProcessingFilter();
filter.setAuthenticationManager(authenticationManager);
filter.setSuccessHandler(new CustomAuthenticationSuccessHandler(mesUrl));
filter.setFailureHandler(new CustomAuthenticationFailureHandler());
return filter;
}
/**
* 创建一个KerberosServiceAuthenticationProvider实例。
*/
@Bean
public KerberosServiceAuthenticationProvider kerberosServiceAuthenticationProvider() {
KerberosServiceAuthenticationProvider provider =
new KerberosServiceAuthenticationProvider();
provider.setTicketValidator(sunJaasKerberosTicketValidator());
provider.setUserDetailsService(dummyUserDetailsService());
return provider;
}
/**
* 创建一个SunJaasKerberosTicketValidator实例。
*/
@Bean
public SunJaasKerberosTicketValidator sunJaasKerberosTicketValidator() {
SunJaasKerberosTicketValidator ticketValidator =
new SunJaasKerberosTicketValidator();
ticketValidator.setServicePrincipal(servicePrincipal);
ticketValidator.setKeyTabLocation(new FileSystemResource(keytabLocation));
ticketValidator.setDebug(true);
return ticketValidator;
}
/**
* 创建一个DummyUserDetailsService实例。
*/
@Bean
public DummyUserDetailsService dummyUserDetailsService() {
return new DummyUserDetailsService();
}
}
- 实现了一个简单的用户详细服务,用于从配置中获取用户信息。
/**
* 实现了一个简单的用户详细服务,用于从配置中获取用户信息。
*/
public class DummyUserDetailsService implements UserDetailsService {
@Value("${app.ad-domainSuffix}")
private String domainSuffix;
/**
* 根据用户名加载用户详细信息。
*
* @param username 用户名
* @return 用户详细信息对象
* @throws UsernameNotFoundException 如果找不到用户
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 移除域后缀
username = username.replaceAll(domainSuffix, "");
// 创建一个简单的用户对象
return new User(
username, // 用户名
"notUsed", // 密码(这里不实际使用)
true, // 账户是否未过期
true, // 账户是否未锁定
true, // 凭证是否未过期
true, // 账户是否启用
AuthorityUtils.createAuthorityList("ROLE_USER")); // 角色列表
}
}
- 自定义身份验证成功处理程序,用于处理身份验证成功后的重定向(重定向到原有系统登录接口)。
/**
2. 自定义身份验证成功处理程序,用于处理身份验证成功后的重定向。
*/
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
/**
* 构造函数,初始化 URL。
*
* @param mesUrl URL 地址
*/
public CustomAuthenticationSuccessHandler(String url) {
this.url = url;
}
private String url;
/**
* 在身份验证成功时调用此方法。
*
* @param httpServletRequest HTTP 请求
* @param httpServletResponse HTTP 响应
* @param authentication 身份验证对象
* @throws IOException 如果发生 I/O 错误
* @throws ServletException 如果发生 Servlet 异常
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
// 获取当前认证的用户对象
User user = (User) authentication.getPrincipal();
// 加密用户名 使用对称加密,避免明文传输
String accoutName = "";
try {
accoutName = SymmetricEncryptionUtil.encrypt(user.getUsername());
} catch (Exception e) {
throw new RuntimeException(e);
}
// 构建重定向 URL
String redirectUrl = mesUrl + "?accoutname="+accoutName;
// 重定向到 URL
httpServletResponse.sendRedirect(redirectUrl);
}
}
- 自定义身份验证失败处理程序,用于处理身份验证失败的情况。
/**
1. 自定义身份验证失败处理程序,用于处理身份验证失败的情况。
*/
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
/**
* 当身份验证失败时调用此方法。
*
* @param httpServletRequest HTTP 请求
* @param httpServletResponse HTTP 响应
* @param e 身份验证异常
* @throws IOException 如果发生 I/O 错误
* @throws ServletException 如果发生 Servlet 异常
*/
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
// 设置响应头为 SPNEGO 协议
httpServletResponse.addHeader("WWW-Authenticate", "Negotiate");
// 设置 HTTP 状态码为 401(未授权)
httpServletResponse.setStatus(401);
// 获取请求调度器以转发到登录页面
RequestDispatcher dispatcher = httpServletRequest.getRequestDispatcher("/login");
// 将请求和响应转发到登录页面
dispatcher.forward(httpServletRequest, httpServletResponse);
}
}
- 此时用户登录电脑系统后打开系统网站仍需输入账号密码,此密码为域账户的用户名与密码,也可实现登录域账户即可登录系统。如想不输入账户密码,直接使用登录电脑的账户进行登录系统,按照下面步骤配置即可。
Step 1:打开控制面板\网络和 Internet
Step 2: 点击“Internet选项”,点击“安全”选项卡,选择需要控制的范围,点击“自定义级别”,找到并选中“自动使用当前用户名和密码登录”即可