Spring Security
核心概念
类名 | 概念 |
---|---|
AuthenticationManager | 用户认证的管理类,所有的认证请求(比如login)都会通过提交一个token给AuthenticationManager的authenticate()方法来实现。当然事情肯定不是它来做,具体校验动作会由AuthenticationManager将请求转发给具体的实现类来做。根据实现反馈的结果再调用具体的Handler来给用户以反馈。 |
AuthenticationProvider | 认证的具体实现类,一个provider是一种认证方式的实现,比如提交的用户名密码我是通过和DB中查出的user记录做比对实现的,那就有一个DaoProvider;如果我是通过CAS请求单点登录系统实现,那就有一个CASProvider。按照Spring一贯的作风,主流的认证方式它都已经提供了默认实现,比如DAO、LDAP、CAS、OAuth2等。前面讲了AuthenticationManager只是一个代理接口,真正的认证就是由AuthenticationProvider来做的。一个AuthenticationManager可以包含多个Provider,每个provider通过实现一个support方法来表示自己支持那种Token的认证。AuthenticationManager默认的实现类是ProviderManager。 |
UserDetailService | 用户认证通过Provider来做,所以Provider需要拿到系统已经保存的认证信息,获取用户信息的接口spring-security抽象成UserDetailService。 |
AuthenticationToken | 用户认证通过Provider来做,所以Provider需要拿到系统已经保存的认证信息,获取用户信息的接口spring-security抽象成UserDetailService。 |
AuthenticationToken | 所有提交给AuthenticationManager的认证请求都会被封装成一个Token的实现,比如最容易理解的UsernamePasswordAuthenticationToken。 |
SecurityContext | 当用户通过认证之后,就会为这个用户生成一个唯一的SecurityContext,里面包含用户的认证信息Authentication。通过SecurityContext我们可以获取到用户的标识Principle和授权信息GrantedAuthrity。在系统的任何地方只要通过SecurityHolder.getSecruityContext()就可以获取到SecurityContext。 |
核心拦截器
所有的过滤器都会实现SpringSecurityFilter安全过滤器。以下表格中的过滤器按照执行顺序排列。
拦截器 | 释义 |
---|---|
HttpSessionContextIntegrationFilter | 位于过滤器顶端,第一个起作用的过滤器。用途一,在执行其他过滤器之前,率先判断用户的session中是否已经存在一个SecurityContext了。如果存在,就把SecurityContext拿出来,放到SecurityContextHolder中,供Spring Security的其他部分使用。如果不存在,就创建一个SecurityContext出来,还是放到SecurityContextHolder中,供Spring Security的其他部分使用。用途二,在所有过滤器执行完毕后,清空SecurityContextHolder,因为SecurityContextHolder是基于ThreadLocal的,如果在操作完成后清空ThreadLocal,会受到服务器的线程池机制的影响。 |
LogoutFilter | 只处理注销请求,默认为/j_spring_security_logout。用途是在用户发送注销请求时,销毁用户session,清空SecurityContextHolder,然后重定向到注销成功页面。可以与rememberMe之类的机制结合,在注销的同时清空用户cookie。 |
AuthenticationProcessingFilter | 处理form登陆的过滤器,与form登陆有关的所有操作都是在此进行的。默认情况下只处理/j_spring_security_check请求,这个请求应该是用户使用form登陆后的提交地址此过滤器执行的基本操作时,通过用户名和密码判断用户是否有效,如果登录成功就跳转到成功页面(可能是登陆之前访问的受保护页面,也可能是默认的成功页面),如果登录失败,就跳转到失败页面。 |
DefaultLoginPageGeneratingFilter | 此过滤器用来生成一个默认的登录页面,默认的访问地址为/spring_security_login,这个默认的登录页面虽然支持用户输入用户名,密码,也支持rememberMe功能,但是因为太难看了,只能是在演示时做个样子,不可能直接用在实际项目中。 |
BasicProcessingFilter | 此过滤器用于进行basic验证,功能与AuthenticationProcessingFilter类似,只是验证的方式不同。 |
SecurityContextHolderAwareRequestFilter | 此过滤器用来包装客户的请求。目的是在原始请求的基础上,为后续程序提供一些额外的数据。比如getRemoteUser()时直接返回当前登陆的用户名之类的。 |
RememberMeProcessingFilter | 此过滤器实现RememberMe功能,当用户cookie中存在rememberMe的标记,此过滤器会根据标记自动实现用户登陆,并创建SecurityContext,授予对应的权限。 |
AnonymousProcessingFilter | 为了保证操作统一性,当用户没有登陆时,默认为用户分配匿名用户的权限。 |
ExceptionTranslationFilter | 此过滤器的作用是处理FilterSecurityInterceptor 中抛出的异常,然后将请求重定向到对应页面,或返回对应的响应错误代码 |
SessionFixationProtectionFilter | 防御会话伪造攻击。 |
FilterSecurityInterceptor | 用户的权限控制都包含在这个过滤器中。功能一:如果用户尚未登陆,则抛出AuthenticationCredentialsNotFoundException“尚未认证异常”。功能二:如果用户已登录,但是没有访问当前资源的权限,则抛出AccessDeniedException“拒绝访问异常”。功能三:如果用户已登录,也具有访问当前资源的权限,则放行。我们可以通过配置方式来自定义拦截规则 |
执行流程
(AuthenticationToken)
AuthenticationFilter ---------------------> AuthenticationManager --> AuthenticationProvider
WebSecurityConfigurerAdapter configure()方法作用
配置SpringSecurity时一般集成WebSecurityConfigurerAdapter
抽象类进行配置。以下方法按照执行顺序排列。
void init(final WebSecurity web)
final HttpSecurity getHttp()
AuthenticationManager authenticationManager()
void configure(AuthenticationManagerBuilder auth)
UserDetailsService userDetailsService()
void configure(HttpSecurity http)
void configure(WebSecurity web)
WEB安全配置。作用如下:
- 一般常用于忽略某些请求,且这些请求一般是静态资源(动态资源一般通过
void configure(HttpSecurity http)
配置为“所有用户可用”)。
UserDetailsService userDetailsServiceBean()
仅用于将UserDetailsService
实例公开为Bean。
AuthenticationManager authenticationManagerBean()
仅用于将AuthenticationManager
实例公开为Bean。
集成JWT示例
登陆阶段流程
1. 登陆阶段流程图。 中间省略了Spring Security 的某些调用。仅用来描绘自己代码的逻辑。
AuthenticationException 子类
异常 | 说明 |
---|---|
UsernameNotFoundException | 无法通过用户名找到用户 |
BadCredentialsException | 凭据无效。要引发此异常,这意味着该帐户既没有被锁定也没有被禁用。 |
AccountStatusException| AccountExpiredException| CredentialsExpiredException| DisabledException| LockedException | 用于由特定用户帐户状态 (锁定,禁用等) 引起的身份验证异常的基类。不断言凭据是否有效。| 帐户已过期。| 帐户的凭据已过期。| 帐户被禁用。| 帐户被锁定。 |
AuthenticationCredentialsNotFoundException | 如果身份验证请求被拒绝,则抛出,因为Authentication 对象中没有SecurityContext 。 |
InsufficientAuthenticationException | 如果由于凭据不够受信任而拒绝了身份验证请求,则抛出。 |
AuthenticationServiceException| InternalAuthenticationServiceException | 如果由于系统问题而无法处理身份验证请求,则抛出。例如,如果后端身份验证存储库不可用,这可能会引发。| 如果由于内部发生的系统问题而无法处理身份验证请求,则抛出。它不同于AuthenticationServiceException因为如果外部系统有内部错误或故障,它不会被抛出。这样可以确保我们可以处理与其他系统的错误明显不同的控制范围内的错误。这种区别的好处是,不受信任的外部系统不应该能够填满日志并导致过多的IO。但是,内部系统应该报告错误。例如,如果后端身份验证存储库不可用,这可能会引发。但是,如果使用OpenID提供程序验证OpenID响应时发生错误,则不会抛出该错误。 |
ProviderNotFoundException | 未找到提供者异常 |
RememberMeAuthenticationException| CookieTheftException| InvalidCookieException | 记住我身份验证| Cookie盗窃| 无效的Cookie |
SessionAuthenticationException | 被一个会话认证策略指示身份验证对象对当前会话无效,通常是因为同一用户已超过了他们同时允许的会话数。 |
NonceExpiredException | 如果由于摘要随机码已过期而拒绝了身份验证请求,则抛出。 |
## 常见HTTP状态码
403 - Forbidden - Access Denied | 禁止 - 拒绝访问
常见问题
-
通过配置文件配置的用户不生效、密码错误或者报错?
a. 先确认看有没有明确配置了
PasswordEncoder
Bean,如果没有配置,则Spring会采用默认的实现(DelegatingPasswordEncoder
),且当配置的密码是明文是Spring会自动加上{noop}
前缀,这样就能通过DelegatingPasswordEncoder
进行校验了。但是一旦明确声明了PasswordEncoder
,则不管在哪里配置,配置时必须使用密文,确保当前生效的PasswordEncoder
能进行校验。b. 如果配置了
AuthenticationManager
、AuthenticationProvider
和UserDetailsService
等Bean时,配置文件配置的用户将不会生效,详情参见:UserDetailsServiceAutoConfiguration
和ReactiveUserDetailsServiceAutoConfiguration
等。c. 如果重写了
WebSecurityConfigurerAdapter#configure(AuthenticationManagerBuilder)
方法则 -
用户不存在时打印堆栈
-
通过
public void configure(HttpSecurity http)
忽略掉的请求还是会走过滤链路。
示例
SpringBoot 2.7.X(spring-security 5.7.x)
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.iboxchain.middleware.commons.exception.ErrorInfo;
import com.iboxchain.middleware.commons.kit.JacksonKit;
import com.iboxchain.middleware.commons.kit.StringKit;
import com.iboxpay.bsmp.module.app.domain.entity.user.User;
import com.iboxpay.bsmp.module.app.domain.repository.UserRepository;
import com.iboxpay.bsmp.module.app.domain.service.CacheContext;
import com.iboxpay.bsmp.module.app.domain.service.DevOpsAccService;
import com.iboxpay.bsmp.module.app.domain.service.QyWxService;
import com.iboxpay.bsmp.common.dto.AccType;
import com.iboxpay.bsmp.common.dto.DevOpsUserAuthenticated;
import com.iboxpay.bsmp.common.dto.LdapAuthenticationToken;
import com.iboxpay.bsmp.common.dto.PhoneWrapper;
import com.iboxpay.bsmp.common.dto.QywxAuthenticationToken;
import com.iboxpay.bsmp.common.dto.UsernameWrapper;
import com.iboxpay.bsmp.common.error.CustomAccessDeniedException;
import com.iboxpay.bsmp.common.error.ErrorConstants;
import com.iboxpay.bsmp.common.error.ErrorResult;
import com.iboxpay.bsmp.common.security.AcmAction;
import com.iboxpay.bsmp.common.security.AcmActionAuthorizationManager;
import com.iboxpay.bsmp.common.security.AcmAuthorizationProvider;
import com.iboxpay.bsmp.common.security.AcmUtils;
import com.iboxpay.bsmp.common.security.ActionDefinition;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.aop.Advisor;
import org.springframework.aop.Pointcut;
import org.springframework.aop.support.ComposablePointcut;
import org.springframework.aop.support.Pointcuts;
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Role;
import org.springframework.core.annotation.Order;
import org.springframework.data.domain.AuditorAware;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authorization.method.AuthorizationInterceptorsOrder;
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.HashMap;
import java.util.Optional;
import java.util.regex.Pattern;
import static org.springframework.util.ObjectUtils.isEmpty;
/**
* SpringSecurity相关配置.
* <p>
* 由于登录和普通接口认证模式不一样,即登录采用用户名和密码进行认证,而普通接口采用JWT(登录生成的)认证,
* 所以定义了两条不同的过滤链,参见<a href="https://docs.spring.io/spring-security/reference/servlet/configuration/java.html#_multiple_httpsecurity">Spring官网</a>.
*
* @author chendaiwang
* @since 2.0.8
*/
@SuppressWarnings("deprecation")
@Slf4j
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfiguration {
/**
* 自定义方法拦截和认证(通过AOP拦截{@link AcmAction}注解注释的所有类和方法进行调用认证).
* 该逻辑可以使用Spring原生注解"{@code @PreAuthorize("@acm.auth(authentication, T(com.iboxpay.bsmp.common.security.ActionDefinition).SMS_GET_MSG)")}"
* 进行代替,但是由于用户固定,并且 在表达式中写枚举需要带上类的全路径,所以通过自定义注解方式可用简化"{@code @AcmAction(ActionDefinition.SMS_GET_MSG)}".
*/
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
Advisor acmAuthorizationMethodInterceptor(AcmAuthorizationProvider acmAuthorizationProvider) {
// 使用注解定义认证管理器适用的切入点 - 该代码复制自Spring
final Pointcut pointcut = new ComposablePointcut(Pointcuts.union(new AnnotationMatchingPointcut(null, AcmAction.class, true), new AnnotationMatchingPointcut(AcmAction.class, true)));
final AcmActionAuthorizationManager authorizationManager = new AcmActionAuthorizationManager(acmAuthorizationProvider);
final AuthorizationManagerBeforeMethodInterceptor interceptor = new AuthorizationManagerBeforeMethodInterceptor(pointcut, authorizationManager);
interceptor.setOrder(AuthorizationInterceptorsOrder.PRE_AUTHORIZE.getOrder());
return interceptor;
}
//#region 安全过滤链配置(多个) 由于登录(认证)有两种(用户名密码换取Token/Token登录),且这两步为互斥关系,所以需要分别配置(参考Spring官网)
/**
* 用户名登录(用户名密码换取Token)。
* 将其设置为 @Order(1) 表示有先考虑,但这部分配置仅仅对"/api/login"有效,
* 其他配置会全部采用另外一部分配置。
*/
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http, DevOpsAccService devopsAccService, QyWxService qyWxService, AppAuthenticationSuccessHandler authenticationSuccessHandler, AppAuthenticationFailureHandler authenticationFailureHandler) throws Exception {
// 域账号(LDAP)登录过滤器
final LdapAuthenticationFilter ldapAuthenticationFilter = new LdapAuthenticationFilter();
ldapAuthenticationFilter.setAuthenticationManager(new ProviderManager(new LdapAuthenticationProvider(devopsAccService)));
ldapAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
ldapAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
// 企业微信登录过滤器
final QywxAuthenticationFilter qywxAuthenticationFilter = new QywxAuthenticationFilter();
qywxAuthenticationFilter.setAuthenticationManager(new ProviderManager(new QywxAuthenticationProvider(qyWxService)));
qywxAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
qywxAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
return http
.antMatcher("/api/login")
// 默认会产生一个hidden标签 里面有安全相关的验证 防止请求伪造 目前不需要 先禁用
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
// 禁用 Session (因为使用JWT)
.sessionManagement(config -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(AbstractHttpConfigurer::disable)
// 这里将“LDAP 登录过滤器”和“企业微信登录过滤器”添加到“用户名密码过滤器”前,这样就有三个认证过滤器(分别处理三种方式登录)
.addFilterBefore(ldapAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(qywxAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// 如果既不是“LDAP 登录”也不是“企业微信登录”,则说明是系统本身的账号体系,此时使用系统默认的登录逻辑
.formLogin(config -> config
.loginPage("/api/login")
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
)
.build();
}
/**
* 除"/api/login"之外的安全配置。
* 这些url除了忽略的(任何人都可以访问)之外,其余的都需要带上Token(JWT)才能访问。
*/
@Bean
public SecurityFilterChain formLoginFilterChain(HttpSecurity http, ApplicationProperties applicationProperties, AuthenticationEntryPoint authenticationEntryPoint, AccessDeniedHandler accessDeniedHandler, CacheContext cacheService) throws Exception {
// JWT身份验证过滤器
final JwtTokenAuthenticationFilter jwtFilter = new JwtTokenAuthenticationFilter(cacheService, applicationProperties);
return http
// 默认会产生一个hidden标签 里面有安全相关的验证 防止请求伪造 目前不需要 先禁用
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
// 禁用 Session (因为使用JWT)
.sessionManagement(config -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
/*
* authorizeRequests 的改进 - https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html
* 1. 使用简化的AuthorizationManagerAPI,而不是元数据源、配置属性、决策管理器和选民。这简化了重用和定制。
* 2. 延迟Authentication查找。而不是需要为每个请求查找身份验证,它只会在授权决策需要身份验证的请求中查找它。
* 3. 基于 Bean 的配置支持。
*/
.authorizeHttpRequests(config -> config
// Actuator 相关API
.antMatchers("/actuator/**").permitAll()
// 配置中心SDK读取配置
.antMatchers("/api/conf/v1/get").permitAll()
.antMatchers("/api/config/v1/get").permitAll()
// 远程刷新插件
.antMatchers("/api/refresh/**").permitAll()
// 健康检查
.antMatchers("/healthCheck", "/k8s/**").permitAll()
// 获取基本元数据
.antMatchers("/api/sys/metadata").permitAll()
// 重定向地址
.antMatchers("/api/sys/redirect").permitAll()
// 手动触发的一些API
.antMatchers("/api/tmp/**").permitAll()
.anyRequest().authenticated()
)
/*
* 一种认证方式,即 Basic Auth。
* 一般临时用于ajax请求的场景,因为默认情况下,如果没有登录的话,服务器会重定向到登录页面,但是ajax请求一般无法处理重定向.
* 而浏览器默认实现了对 Basic Auth 验证方式的支持(能识别到需要 Basic Auth 方式的身份验证并提供用户名密码输入框等)
* 标准认证流程:
* 1.服务端检测到请求头没有"Authorization: Basic ***"请求头,则返回状态码为401,请求头为'WWW-Authenticate: Basic realm="**"'的响应
* 2.当客户端收到含有该响应头的内容后会弹出一个对话框,要求用户输入验证信息(username 和 password)
* 3.客户端得到用户名密码之后,在后面的所有请求中都会包含'Authorization: Basic <base64(username:password)>'的请求头
* spring-security 认证流程:
* 在标准的基础上进行了一些优化,即优先采用 session 认证,如果 session 认证失败时才采用 Basic Auth 认证,
* 这应该是认证管理器的默认顺序带来的特性.
*/
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(config -> config
// 401 - 未认证处理策略
.authenticationEntryPoint(authenticationEntryPoint)
// 403 - 未授权处理策略
.accessDeniedHandler(accessDeniedHandler)
)
.anonymous(Customizer.withDefaults())
.build();
}
//#endregion
//#region private
/**
* 将字符串账号类型解析为枚举.
*/
private static AccType parseAccType(String accTypeStr) {
if (isEmpty(accTypeStr)) {
log.warn("没有指定登录类型");
throw new BadCredentialsException("用户名或密码错误");
}
try {
return AccType.valueOf(accTypeStr);
} catch (IllegalArgumentException e) {
log.warn("accType 值错误,将视为用户名或密码错误");
// 如果类型非法则说明类型指定错误,将其当作密码错误处理
throw new BadCredentialsException("用户名或密码错误");
}
}
//#endregion
//#region Class
/**
* Ldap账号认证 - 仅用于LDAP类型的登录,即 accType=LDAP.
*/
public static class LdapAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
protected LdapAuthenticationFilter() {
// 设置需要过滤的路径
super(new AntPathRequestMatcher("/api/login", "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
final String userName = request.getParameter("username");
final String password = request.getParameter("password");
//创建未认证的凭证(etAuthenticated(false)),注意此时凭证中的主体principal为用户名
LdapAuthenticationToken authenticationToken = new LdapAuthenticationToken(userName, password);
//将认证详情(ip,sessionId)写到凭证
authenticationToken.setDetails(new WebAuthenticationDetails(request));
//AuthenticationManager获取受支持的AuthenticationProvider(这里也就是JwtAuthenticationProvider),
//生成已认证的凭证,此时凭证中的主体为userDetails --- 这里会委托给AuthenticationProvider实现类来验证
// 即 跳转到 JwtAuthenticationProvider.authenticate 方法中认证
return this.getAuthenticationManager().authenticate(authenticationToken);
}
/**
* 判断本次请求是否适用于本过滤器。
*/
@Override
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
if (super.requiresAuthentication(request, response)) {
return parseAccType(request.getParameter("accType")) == AccType.LDAP;
}
return false;
}
}
/**
* 企业微信认证 - 仅用于企业微信回调登录,即 accType=QYWX.
*/
public static class QywxAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
protected QywxAuthenticationFilter() {
// 设置需要过滤的路径
super(new AntPathRequestMatcher("/api/login", "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
final String code = request.getParameter("code");
//创建未认证的凭证(etAuthenticated(false)),注意此时凭证中的主体principal为用户名
QywxAuthenticationToken authenticationToken = new QywxAuthenticationToken(code);
//将认证详情(ip,sessionId)写到凭证
authenticationToken.setDetails(new WebAuthenticationDetails(request));
//AuthenticationManager获取受支持的AuthenticationProvider(这里也就是JwtAuthenticationProvider),
//生成已认证的凭证,此时凭证中的主体为userDetails --- 这里会委托给AuthenticationProvider实现类来验证
// 即 跳转到 JwtAuthenticationProvider.authenticate 方法中认证
return this.getAuthenticationManager().authenticate(authenticationToken);
}
/**
* 判断本次请求是否适用于本过滤器。
*/
@Override
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
if (super.requiresAuthentication(request, response)) {
return parseAccType(request.getParameter("accType")) == AccType.QYWX;
}
return false;
}
}
/**
* 客户端携带JWT进行登录时进行的认证.
*/
@Slf4j
public static class JwtTokenAuthenticationFilter extends OncePerRequestFilter {
private final ApplicationProperties applicationProperties;
private static final Pattern BEARER = Pattern.compile("Bearer ");
private final CacheContext cacheService;
/**
* JWT验证实例,该实例可以重用.
*/
private final JWTVerifier verifier;
public JwtTokenAuthenticationFilter(CacheContext cacheService, ApplicationProperties applicationProperties) {
this.cacheService = cacheService;
this.applicationProperties = applicationProperties;
verifier = JWT.require(Algorithm.HMAC256(applicationProperties.getSys().getJwtSecret())).withIssuer(applicationProperties.getSys().getIssuer()).build();
}
@Override
protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain) throws ServletException, IOException {
// 如果 jwtToken 不正确(包含没有值或者值不正确)则直接 doFilter ,表示本过滤器不支持
try {
final String token = request.getHeader("Authorization");
if (!StringKit.hasLength(token)) {
return;
}
final String jwtToken = BEARER.matcher(token).replaceFirst("");
// 进行签名验证,防止Token伪造
final DecodedJWT jwt = verifier.verify(jwtToken);
// 用户名
final String username = jwt.getSubject();
log.info("Username: {}", username);
// 根据用户名查询用户
final Optional<User> userOptional = cacheService.getUserByUsername(username);
if (!userOptional.isPresent()) {
return;
}
final User user = userOptional.get();
// 进行密码版本验证,实现修改密码后所有Token都失效
if (user.getTokenVersion() != jwt.getClaim(applicationProperties.getSys().getTokenVersion()).asInt()) {
return;
}
final UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} catch (AccessDeniedException ignored) {
} catch (JWTVerificationException e) {
log.info("Token无效: {}", e.getMessage());
} catch (Exception e) {
log.error("登陆认证失败", e);
} finally {
chain.doFilter(request, response);
}
}
}
/**
* 自定义登录认证方式,域账号登录认证实现,实际是调用Devops接口做认证.
*/
public static class LdapAuthenticationProvider implements AuthenticationProvider {
private final DevOpsAccService devopsAccService;
public LdapAuthenticationProvider(DevOpsAccService devopsAccService) {
this.devopsAccService = devopsAccService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
final LdapAuthenticationToken token = (LdapAuthenticationToken) authentication;
// Determine username
final String username = authentication.getName();
final String password = (String) token.getCredentials();
// 根据用户名密码请求Devops接口查询用户信息,如果用户名密码错误则抛出异常
final DevOpsUserAuthenticated devopsUser = devopsAccService.getUser(username, password);
// 该用户可能是第一次登录或者用户信息有更新,需要将最新的数据保存到本地
final User user = devopsAccService.savaOrUpdateUser(devopsUser);
// Ensure we return the original credentials the user supplied,
// so subsequent attempts are successful even with encoded passwords.
// Also ensure we return the original getDetails(), so that future
// authentication events after cache expiry contain the details
final LdapAuthenticationToken result = new LdapAuthenticationToken(user, token.getCredentials(), user.getAuthorities());
result.setDetails(authentication.getDetails());
return result;
}
@Override
public boolean supports(Class<?> authentication) {
return LdapAuthenticationToken.class.isAssignableFrom(authentication);
}
}
/**
* 自定义登录认证方式,企业微信登录认证实现,实际是调用企业微信的接口获取用户信息.
*/
public static class QywxAuthenticationProvider implements AuthenticationProvider {
private final QyWxService qyWxService;
public QywxAuthenticationProvider(final QyWxService qyWxService) {
this.qyWxService = qyWxService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
final QywxAuthenticationToken token = (QywxAuthenticationToken) authentication;
final String code = (String) token.getCredentials();
// 请求企业微信进行认证
final String resultStr = qyWxService.getWxUserIdByCode(code);
final JsonNode resultNode = JacksonKit.jsonToObject(resultStr);
final int errCode = resultNode.path("errcode").asInt();
if (errCode == 40029) {
throw new BadCredentialsException("处理超时,请重新扫码操作!");
}
if (errCode != 0) {
throw new InternalAuthenticationServiceException("处理错误,请重试或者换用其他方式登录");
}
// 根据企业微信用户id查询对应的系统用户
// 调用企业微信接口获取用户信息
final JsonNode userNode = JacksonKit.jsonToObject(qyWxService.getUserInfo(resultNode.path("UserId").asText()));
if (userNode.path("errcode").asInt() != 0) {
throw new InternalAuthenticationServiceException("处理错误,请稍后再试");
}
final User user = qyWxService.savaOrUpdateUser(userNode);
final LdapAuthenticationToken result = new LdapAuthenticationToken(user, token.getCredentials(), user.getAuthorities());
result.setDetails(authentication.getDetails());
return result;
}
@Override
public boolean supports(Class<?> authentication) {
return QywxAuthenticationToken.class.isAssignableFrom(authentication);
}
}
/**
* 从数据库加载用户.
* <p>
* 由于用户的密码有可能为空(比如通过其他账号进行登录的情况),而SpringSecurity的User要求密码不能为null和为”“,
* 所以当用户密码为空时将其看做是拥有一个随机密码。
* 即密码为空的情况下,登录时将提示”密码错误“。
*/
@Component
public static class DaoUserDetailsServiceImpl implements UserDetailsService {
private final CacheContext cacheService;
private final UserRepository userRepository;
public DaoUserDetailsServiceImpl(CacheContext cacheService, UserRepository userRepository) {
this.cacheService = cacheService;
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 解析”用户名“,用户名支持多种登录方式,比如手机和邮箱等,所以需要判断类型
final UsernameWrapper usernameWrapper = new UsernameWrapper(username);
final UsernameWrapper.Type usernameType = usernameWrapper.getType();
// 根据不同类分别从数据库查询
final Optional<User> dbUserOptional;
if (usernameType == UsernameWrapper.Type.USERNAME) {
dbUserOptional = cacheService.getUserByUsername((String) usernameWrapper.getValue());
} else if (usernameType == UsernameWrapper.Type.EMAIL) {
dbUserOptional = userRepository.findByEmail((String) usernameWrapper.getValue());
} else if (usernameType == UsernameWrapper.Type.PHONE_NUM) {
dbUserOptional = userRepository.findByPhoneNum((PhoneWrapper) usernameWrapper.getValue());
} else {
throw new RuntimeException(usernameType + " 类型的用户名暂不支持登录.");
}
if (!dbUserOptional.isPresent()) {
throw new UsernameNotFoundException("用户不存在");
}
return dbUserOptional.get();
}
}
/**
* 登录成功处理(返回Token).
* 无论是使用系统用户还是LDAP用户,登录成功后都是统一使用该处理器.
*/
@Component
public static class AppAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
/**
* 生成 JWT 的签名密匙.
* 为了简单,这里生成签名的密匙直接明文写死.
* 后续如果需要提高安全性则可以做成外部配置的,但是要考虑多机部署时密匙共享问题.
*/
private final Algorithm algorithmHs;
private final CacheContext cacheContext;
private final ApplicationProperties applicationProperties;
/**
* @param applicationProperties 用于获取生成 JWT 的签名密匙,使用 HmacSHA256 算法
*/
public AppAuthenticationSuccessHandler(ApplicationProperties applicationProperties, final CacheContext cacheContext) {
algorithmHs = Algorithm.HMAC256(applicationProperties.getSys().getJwtSecret());
this.applicationProperties = applicationProperties;
this.cacheContext = cacheContext;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
User user = (User) authentication.getPrincipal();
// 由于领导要求,不能所有人都能登录,所以如果登录成功之后需要判断该用户是否允许登录(目前如果不允许登录的需要提示其发邮件申请,邮件需要抄送自己的上级领导)
final ApplicationProperties.Sys sysConf = applicationProperties.getSys();
if (sysConf.getAllowLogin().isEnable() && !sysConf.getAdminUsers().contains(user.getUsername()) && !sysConf.getAllowLogin().getUsernames().contains(user.getUsername())) {
log.warn("帐号 {} 可以成功登录,但该帐号不允许登录", user.getUsername());
// 将邮箱地址通过错误详情传递给前端
final HashMap<String, String> detail = new HashMap<>(1);
detail.put("email", sysConf.getAllowLogin().getHandleEmail());
final ErrorResult<HashMap<String, String>> result = new ErrorResult<>(HttpStatus.UNAUTHORIZED, request, ErrorConstants.UNAUTHORIZED_NOT_APPLIED, detail);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write(JacksonKit.objectToJson(result));
return;
}
// JWT 的作用只是用于取代传统的 SESSION ID,其中不携带任何数据给客户端,但是服务端需要其中包含的主体(sub)来验证该JWT的身份
final Date now = new Date();
final Date exp = Date.from(LocalDateTime.now().plus(sysConf.getEffectiveTime()).atZone(ZoneId.systemDefault()).toInstant());
// 认证token
String token = JWT.create()
.withIssuer(sysConf.getIssuer())
.withSubject(user.getUsername())
.withIssuedAt(now)
.withExpiresAt(exp)
// 加入用户密码版本实现更改密码后老Token全部失效
.withClaim(sysConf.getTokenVersion(), user.getTokenVersion())
.sign(algorithmHs);
final ObjectNode node = JsonNodeFactory.instance.objectNode();
node.put("token", token);
node.put("exp", exp.getTime() / 1000);
node.put("iat", now.getTime() / 1000);
response.getWriter().write(node.toString());
// 登录成功后删除用户相关的缓存
cacheContext.cleanUser(user.getUsername());
cacheContext.cleanPolicyByUser(user.getId());
}
}
/**
* 登录失败处理.
* TODO 还需要继续完善,比如将其封装为包含错误码的固定格式.
*/
@Component
public static class AppAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
final ErrorInfo errorInfo;
if (exception instanceof BadCredentialsException || exception instanceof UsernameNotFoundException) {
errorInfo = ErrorConstants.UNAUTHORIZED_BAD_CREDENTIALS;
} else if (exception instanceof LockedException) {
errorInfo = ErrorConstants.UNAUTHORIZED_LOCKED;
} else if (exception instanceof CredentialsExpiredException) {
errorInfo = ErrorConstants.UNAUTHORIZED_CREDENTIALS_EXPIRED;
} else if (exception instanceof AccountExpiredException) {
errorInfo = ErrorConstants.UNAUTHORIZED_ACCOUNT_EXPIRED;
} else if (exception instanceof DisabledException) {
errorInfo = ErrorConstants.UNAUTHORIZED_DISABLED;
} else {
errorInfo = ErrorConstants.UNAUTHORIZED_BAD_CREDENTIALS;
}
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write(JacksonKit.objectToJson(new ErrorResult<>(HttpStatus.UNAUTHORIZED, request, errorInfo, null)));
}
}
/**
* 401 - 未认证处理.
*/
@Component
public static class ForbiddenAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
final ErrorResult<String> result = new ErrorResult<>(HttpStatus.UNAUTHORIZED, request, ErrorConstants.UNAUTHORIZED, "未登录");
response.getWriter().write(JacksonKit.objectToJson(result));
}
}
/**
* 403 未授权处理.
*/
@Component
static class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
final Object detail;
if (accessDeniedException instanceof CustomAccessDeniedException) {
final ActionDefinition action = ((CustomAccessDeniedException) accessDeniedException).getAction();
final ObjectNode detailTmp = JsonNodeFactory.instance.objectNode();
detailTmp.put("issue", "无权访问操作 " + action.getCode() + ":" + action.getName());
final ObjectNode actionNode = JsonNodeFactory.instance.objectNode();
actionNode.put("code", action.getCode());
actionNode.put("name", action.getName());
actionNode.put("desc", action.getDesc());
detailTmp.set("action", actionNode);
detail = detailTmp;
} else {
detail = "权限不足";
}
final ErrorResult<Object> result = new ErrorResult<>(HttpStatus.FORBIDDEN, request, ErrorConstants.FORBIDDEN, detail);
response.getWriter().write(JacksonKit.objectToJson(result));
}
}
/**
* 自定义密码编码器.
* 当用户密码为空时永远视为不匹配.
*/
@Component
static class CustomPasswordEncoder implements PasswordEncoder {
private static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder();
@Override
public String encode(CharSequence rawPassword) {
return PASSWORD_ENCODER.encode(rawPassword);
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (encodedPassword == null || encodedPassword.isEmpty()) {
return false;
}
return PASSWORD_ENCODER.matches(rawPassword, encodedPassword);
}
}
/**
* 获取审计用户名.
* <p>
* 用于JPA审计注解时使用.
*/
@Slf4j
@Component
public static class AuditorAwareBean implements AuditorAware<String> {
@Override
public @NotNull Optional<String> getCurrentAuditor() {
try {
return Optional.of(AcmUtils.getUser().getUsername());
} catch (Exception e) {
log.warn("{}", e.getMessage());
return Optional.empty();
}
}
}
//#endregion
}