用户名密码认证、短信验证码认证、踢人下线、 AuthorizationFilter 授权、FilterSecurityInterceptor url / expression 授权、登出、分布式配置。
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
先添加一个 SecurityConfig 配置类,作为整个 spring security 的核心配置,后续的认证、授权等功能都将在整个配置类中完成。
// spring security config
@Configuration
public class SecurityConfig {
// 使用 HttpSecurity 构建器配置一个 SecurityFilterChain bean
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
// csrf
httpSecurity.csrf()
.disable();
// 用户名密码认证
// 短信验证码认证
// 踢人下线
// 认证入口处理器
// AuthorizationFilter 授权
// FilterSecurityInterceptor url 授权
// FilterSecurityInterceptor expression 授权(常用)
// 授权异常处理
// 登出
}
}
用户名密码认证主要是自定义实现 UserDetails、UserDetailsService、AuthenticationSuccessHandler、AuthenticationFailureHandler、AuthenticationEntryPoint 等类,然后将它们配置到 SecurityConfig 中。
// 自定义 UserEntity 类实现 UserDetails 接口
// 实现自己的用户实体类
@Data
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity extends BaseEntity implements UserDetails, Serializable {
private static final long serialVersionUID = -5194298431715212061L;
@TableId(type = IdType.AUTO)
private Long id;
private String account; // 账号(用户名)
private String password; // 密码(凭证)
private Integer accountType; // 账号类型: 0: 全部; 1: 超级管理员; 2: 管理员; 3: 业务员; 4: 用户
private String nickName; // 昵称
private Integer accountExpireState; // 账号过期状态: 0: 全部; 1: 未过期; 2: 已过期
private Integer passwordExpireState; // 密码过期状态: 0: 全部; 1: 未过期; 2: 已过期
private Integer lockState; // 账号锁定状态: 0: 全部; 1: 未锁定; 2: 已锁定
private Integer enableState; // 账号启用状态: 0: 全部; 1: 启用; 2: 禁用
@TableField(exist = false)
private List<String> roleList;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorityList = new ArrayList<>(this.roleList.size());
this.roleList.forEach(role -> authorityList.add(new SimpleGrantedAuthority(role)));
return authorityList;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.account;
}
@Override
public boolean isAccountNonExpired() {
return this.accountExpireState == 1;
}
@Override
public boolean isAccountNonLocked() {
return this.lockState == 1;
}
@Override
public boolean isCredentialsNonExpired() {
return this.passwordExpireState == 1;
}
@Override
public boolean isEnabled() {
return this.enableState == 1;
}
// 内部类 UserEntityDeserializer 自定义的 UserEntity 反序列化器 实现分布式权限管理时会用到
public static class UserEntityDeserializer extends JsonDeserializer<UserEntity> {
@Override
public UserEntity deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
ObjectMapper mapper = (ObjectMapper) jsonParser.getCodec();
JsonNode jsonNode = mapper.readTree(jsonParser);
long id = getJsonNode(jsonNode, "id").asLong();
String account = getJsonNode(jsonNode, "account").asText();
String password = getJsonNode(jsonNode, "password").asText("");
int accountType = getJsonNode(jsonNode, "accountType").asInt();
String nickName = getJsonNode(jsonNode, "nickName").asText();
int accountExpireState = getJsonNode(jsonNode, "accountExpireState").asInt();
int passwordExpireState = getJsonNode(jsonNode, "passwordExpireState").asInt();
int lockState = getJsonNode(jsonNode, "lockState").asInt();
int enableState = getJsonNode(jsonNode, "enableState").asInt();
List<String> roleList = mapper.convertValue(getJsonNode(jsonNode, "roleList"), new TypeReference<List<String>>() {});
return UserEntity.builder()
.id(id)
.account(account)
.password(password)
.accountType(accountType)
.nickName(nickName)
.accountExpireState(accountExpireState)
.passwordExpireState(passwordExpireState)
.lockState(lockState)
.enableState(enableState)
.roleList(roleList)
.build();
}
private JsonNode getJsonNode(JsonNode jsonNode, String field) {
return jsonNode.has(field) ? jsonNode.get(field) : MissingNode.getInstance();
}
}
// 内部类 UserEntityMixin 实现分布式权限管理时会用到
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class")
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonDeserialize(using = UserEntityDeserializer.class)
public abstract static class UserEntityMixin {
}
}
// UserService
public interface UserService extends UserDetailsService {}
// 自定义 UserServiceImpl 类实现 UserDetailsService 接口的 loadUserByUsername() 方法
@Component
public class UserServiceImpl implements UserService {
@Override // 根据用户名(或账号)从数据库加载用户信息、用户权限信息等
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 假装这些信息都是从数据库查到的
List<String> list = new ArrayList<>();
list.add("ADMIN"); // 假装改用拥有 ADMIN 角色
// 注意:新版本中加密后的密码格式变了 {加密方式}密码
// 密码是 123456(下面是 BCryptPasswordEncoder 加密器加密后的结果)
String password = "{bcrypt}$2a$10$j.ufU7p1wg0tEBcgPknxy.Bt7z6uPZWXs/nxsO25/j1tFI/QXWSNq";
return UserEntity.builder()
.account(username)
.password(password)
.roleList(list)
.accountExpireState(1)
.passwordExpireState(1)
.lockState(1)
.enableState(1)
.build();
}
}
// 自定义 MyAuthenticationSuccessHandler 类实现 AuthenticationSuccessHandler
// 实现自己的认证成功处理器
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 这里主要是向返回一个登录成功的提示 具体需要根据业务场景来实现 如返回一个 json 亦或是其它
ResponseUtils.responseJson(response, APIResponse.success("登录成功!"));
}
}
// 自定义 MyAuthenticationFailureHandler 类实现 AuthenticationFailureHanlder
// 实现自己的认证失败处理器
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
String message;
if (exception instanceof UsernameNotFoundException || exception instanceof BadCredentialsException) {
message = "账号或密码错误!";
} else if (exception instanceof LockedException) {
message = "账号被锁定!";
} else if (exception instanceof DisabledException) {
message = "账号被禁用!";
} else if (exception instanceof AccountExpiredException) {
message = "账号已过期!";
} else if (exception instanceof CredentialsExpiredException) {
message = "密码已过期!";
} else {
message = "登录失败!";
}
// 这里主要是向返回一个登录失败的提示 具体需要根据业务场景来实现 如返回一个 json 表示用户名密码错误 亦或是其它
ResponseUtils.responseJson(response, APIResponse.failure(message));
}
}
// 自定义 MyAuthenticationEntryPoint 类实现 AuthenticationEntryPoint
// 实现自己的认证入口处理器
@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 这里主要是向返回一个请先登录的提示 具体需要根据业务场景来实现 如返回一个 json 表示请先登录 然后前端根据整个提示跳转到登录页 亦或是其它
ResponseUtils.responseJson(response, APIResponse.failure("请先登录!"));
}
}
一般情况下,自定义实现一种认证方式需要以下几个步骤:自定义实现一个 Authentication、自定义实现一个 AuthenticationProvider、自定义个实现一个 AuthenticationFilter、自定义个实现一个 SecurityConfigurer 等。spring security 为我们提供了大量相关的接口和抽象类,自定义实现时只需要去实现相应接口或者扩展相应抽象类即可。
// 自定义 SmsAuthenticationToken 类扩展 AbstractAuthenticationToken
// 用来承载短信验证码认证中的一些信息
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = -4459070945583037208L;
private final Object principal; // 认证主体
private Object credentials; // 凭证
public SmsAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
public SmsAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
Assert.isTrue(!isAuthenticated,
"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
super.setAuthenticated(false);
}
}
// 自定义 SmsAuthenticationprovider 类实现 AuthenticationProvider 接口
// AuthenticationProvider 即认证提供器的主要作用是进行认证 简单理解就是验证用户名存不存在 密码正不正确 当然在这里就是手机号存不存在 验证码正不正确
public class SmsAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
/**
* 1、从 redis 中根据 mobilePhone 获取到实际生成的 smsCode
* 2、从 authentication 中取出前端传过来的 smsCode
* 3、检查是否相等
*/
String smsCode = "123456"; // 假设实际生成的短信验证码是 123456
String credentials = (String) authentication.getCredentials();
if (StringUtils.equals(smsCode, credentials)) {
return new SmsAuthenticationToken(authentication.getPrincipal(), authentication.getCredentials(), Collections.emptyList());
}
throw new BadCredentialsException("验证码错误!");
}
@Override
public boolean supports(Class<?> authentication) {
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
}
}
// 自定义 SmsAuthenticatgionFilter 类扩展 AbstractAuthenticationprocessingFilter
// 这个 filter 的主要作用是处理或者触发短信验证码认证
public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
// 以构造函数的方式配置处理器短信验证码认证的 url 匹配器
public SmsAuthenticationFilter() {
super(new AntPathRequestMatcher("/admin/sms/login", HttpMethod.POST.name()));
}
@Override // 开始认证
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
// 从 request 对象中取出参数
String mobilePhone = request.getParameter("mobilePhone");
String smsCode = request.getParameter("smsCode");
// 构建一个我们自定义的认证 token
SmsAuthenticationToken smsAuthenticationToken = new SmsAuthenticationToken(mobilePhone, smsCode);
smsAuthenticationToken.setDetails(this.authenticationDetailsSource.buildDetails(request));
// 调用认证管理器的 authenticate() 方法开始认证这个动作
return this.getAuthenticationManager().authenticate(smsAuthenticationToken);
}
}
// 自定义 SmsAuthenticationConfigurer 类扩展 AbstractAuthenticationFilterConfigurer
// configurer 的主要作用是配置 filter 并将 filter 添加到 SecurityFilterChain 中
@Component
public class SmsAuthenticationConfigurer<B extends HttpSecurityBuilder<B>>
extends AbstractAuthenticationFilterConfigurer<B, SmsAuthenticationConfigurer<B>, SmsAuthenticationFilter> {
// 维护两个处理器
private AuthenticationSuccessHandler successHandler;
private AuthenticationFailureHandler failureHandler;
// 以构造函数的方式实例化父类中维护的 filter
public SmsAuthenticationConfigurer() {
super(new SmsAuthenticationFilter(), null);
}
@Override // 配置 login process url
protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) {
return new AntPathRequestMatcher(loginProcessingUrl);
}
@Override // 实现配置方法 配置 SmsAuthenticationFilter
public void configure(B http) throws Exception {
// 通过调用父类的 getAuthenticationFilter() 方法拿到执行构造函数设置到父类中的 filter
SmsAuthenticationFilter smsAuthenticationFilter = this.getAuthenticationFilter();
// 从 HttpSecurity 中取出共享对象 AuthenticationManager 并设置到 filter 中
smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
// 设置处理器
smsAuthenticationFilter.setAuthenticationSuccessHandler(successHandler);
smsAuthenticationFilter.setAuthenticationFailureHandler(failureHandler);
// new 一个自定义的 provider
SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
// 设置 provider 并将 filter 添加到 SecurityFilterChain 维护的 filter 列表中
http.authenticationProvider(smsAuthenticationProvider)
.addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
// 向外部提供两个配置处理器的方法
public SmsAuthenticationConfigurer<B> setSuccessHandler(AuthenticationSuccessHandler successHandler) {
this.successHandler = successHandler;
return this;
}
public SmsAuthenticationConfigurer<B> setFailureHandler(AuthenticationFailureHandler failureHandler) {
this.failureHandler = failureHandler;
return this;
}
}
AuthorizationFilter 授权的主要原理是通过 AuthorizeHttpRequestsConfigurer 配置类来配置要保护的资源以及其对应的角色,最终会产生一个受保护资源与访问其需具备的角色的一对多的映射关系,这一堆一对多的映射关系被维护在一个 list 中。当请求到达时会先根据该请求(资源)获取其对应的角色列表,然后从 authentication 中取出当前用户所具有的角色列表,然后两个 list 取交集,若至少有一个重合,则说明该用户有权访问该资源。该授权方式不常用,看看就行。
// 在 SecurityConfig 中配置权限(在实际应用中应该从数据库中加载这些资源和对应角色)
httpSecurity.authorizeHttpRequests()
.antMatchers("/user/**", "/perm/**", "/role/**") // 要保护的资源一
.hasAnyRole("SUPER-ADMIN", "ADMIN") // 访问资源一需要的角色一
.antMatchers("/web/**") // 要保护的资源二
.hasAnyRole("WEB"); // 访问资源二需要的角色二
FilterSecurityInterceptor url 授权配置方式和 AuthorizationFilter 授权差不多,但内部逻辑不一样。该授权方式也不常用,看看就行。
// 在 SecurityConfig 中配置权限(在实际应用中应该从数据库中加载这些资源和对应角色)
httpSecurity.apply(new UrlAuthorizationConfigurer<>(httpSecurity.getSharedObject(ApplicationContext.class)))
.withObjectPostProcessor(objectPostProcessor) // 这个后置处理器就是用来加载资源对应角色的 后文会讲到
.getRegistry()
.antMatchers("/admin/user/login", "/admin/sms/login") // 这个没实际作用 但是得配一对
.hasAnyRole("LOGIN_ROLE")
FilterSecurityInterceptor expression 授权主要需要配置权限数据源 FilterInvocationSecurityMetadataSource、访问决策管理器 AccessDecisionManager、访问拒绝处理器 AccessDeniedHandler、登录入口 AuthenticationEntryPoint 等。
// 自定义 MySecurityMetadataSource 类实现 FilterInvocationSecurityMetadataSource 接口
// 其作用是管理权限源数据
@Component
public class MySecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
private final Map<String, List<String>> requestMap = new HashMap<>();
// 项目初始化时从数据库加载权限配置信息
@PostConstruct
public void init() {
// 这里应该是查询数据 获取数据库配置的 资源与角色 的映射关系
List<String> roleList = new ArrayList<>();
roleList.add("SUPER_ADMIN");
roleList.add("ADMIN");
roleList.add("MOMO");
requestMap.put("/admin/perm/list", roleList.subList(0, 1));
requestMap.put("/admin/log/list", roleList.subList(0, 2));
requestMap.put("/admin/user/list", roleList.subList(0, 3));
requestMap.put("/admin/log/test", roleList.subList(0, 3));
}
@Override // 实现 getAttributes 方法 其主要作用是将我们自定义的 requestMap 中的数据转换成了集合然后返回
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
String requestUrl = ((FilterInvocation) object).getRequestUrl();
for (Map.Entry<String, List<String>> entry : this.requestMap.entrySet()) {
if (this.antPathMatcher.match(entry.getKey(), requestUrl)) {
return SecurityConfig.createList(entry.getValue().toArray(new String[0]));
}
}
return null;
}
@Override // 获取所有权限配置
public Collection<ConfigAttribute> getAllConfigAttributes() {
Set<ConfigAttribute> configAttributes = new HashSet<>();
this.requestMap.values().forEach(list -> configAttributes.addAll(SecurityConfig.createList(list.toArray(new String[0]))));
return configAttributes;
}
@Override
public boolean supports(Class<?> clazz) {
return FilterInvocation.class.isAssignableFrom(clazz);
}
}
// 自定义 MyAccessDecisionManager 类实现 AccessDecisionManager 接口
// 其主要作用就是匹配权限
@Component
public class MyAccessDecisionManager implements AccessDecisionManager {
// authenticaion 当前登录用户(其中包含了该用户所具有角色)
// object 实际上就是 request 对象(其中包含了 url)
// configAttributes 就是从 MySecurityMetadataSource 的 getAttributes 方法中根据 object 获取到的角色列表
@Override // 角色
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
Collection<? extends GrantedAuthority> grantedAuthorities = authentication.getAuthorities();
Iterator<ConfigAttribute> iterator = configAttributes.iterator();
String requestRole;
while (iterator.hasNext()) {
requestRole = iterator.next().getAttribute();
if (StringUtils.equals("LOGIN_ROLE", requestRole)) {
return; // LOGIN_ROLE 角色为虚拟角色 也就是说 /admin/user/login 资源不需要角色
}
for (GrantedAuthority grantedAuthority : grantedAuthorities) {
if (StringUtils.equals(requestRole, grantedAuthority.getAuthority())) {
return; // 若匹配到 则返回 说明授权成功
}
}
}
throw new AccessDeniedException("无权访问!"); // 若没匹配到则抛出访问被拒绝异常 即授权失败
}
@Override
public boolean supports(ConfigAttribute attribute) {
return false;
}
@Override
public boolean supports(Class<?> clazz) {
return false;
}
}
// 自定义 FilterSecurityInterceptorPostProcessor 类实现 ObjectPostProcessor
// ObjectPostProcessor 接口是一个对象后置处理器接口 而其泛型表示要处理的类类型 这里要处理的就是 FilterSecurityInterceptor
@Component
@RequiredArgsConstructor
public class FilterSecurityInterceptorPostProcessor implements ObjectPostProcessor<FilterSecurityInterceptor> {
private final AccessDecisionManager accessDecisionManager; // 注入自定义的 MyAccessDecisionManager
private final FilterInvocationSecurityMetadataSource securityMetadataSource; // 注入自定义的 MySecurityMetadataSource
// FilterSecurityInterceptor 过滤器的 configurer 在配置其时会调用其后置处理方法 也就是这里的 postProcess
// 调用 postProcess 方法时会把自定义的 manager 和 source 设置进去
// 注:别看 FilterSecurityInterceptor 后缀是拦截器 实际上其是个过滤器 实现了 Filter
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O object) {
object.setAccessDecisionManager(accessDecisionManager);
object.setSecurityMetadataSource(securityMetadataSource);
return object;
}
}
// 自定义 MyAccessDeniedHanlder 类实现 AccessDeniedHandler 接口
// 其主要作用是实现在授权失败后的处理方式 如向前端返回一个 json 提示无权访问
@Slf4j
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception) throws IOException, ServletException {
log.error(exception.getMessage());
ResponseUtils.responseJson(response, APIResponse.failure("无权访问!"));
}
}
// 自定义 MyAuthenticationEntryPoint 实现 AuthenticationEntryPoint 接口
// 其主要作用是实现在未登录的情况下访问一个受保护资源时的处理方式 如返回一个 json 提示 前端根据该提示跳转到登录页面
@Component
@Slf4j
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
log.error(exception.getMessage());
ResponseUtils.responseJson(response, APIResponse.failure("请先登录!"));
}
}
踢人下线的主要应用场景是,对于同一个账号,是否可以允许多人同时登录/同时在线(即多人同时使用一个 vip 账号)、允许同时在线的人数、当到达该刷数量后怎么处理等等。
// 在 SecurityConfig 中配置踢人下线
// 主要是匹配值 SessionManagement
httpSecurity.sessionManagement()
.maximumSessions(2) // 最大 session 数 即同时允许两人在线
// 是否阻止用户登录
// 若为 false 则表示该账号所有登录的 session 中活跃度最低的几个会被删除
// 若为 true 则表示当在线人数达到最大 session 数后 后续要登录的用户将被阻止登录
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry) // 这个是配置分布式权限管理用的
// 配置 session 过期后的处理器
.expiredSessionStrategy(sessionExpiredHandler);
// 自定义 MySessionExpiredHandler 类实现 SessionInformationExpiredStrategy 接口
// 其作用是给被后续登录的用户挤掉的前面登录的用户提示
// 如对于同一个账号 其 maximumSessions 设置为 2 即允许同时两人在线
// A 用户先登录正常使用 B 用户再登录也正常使用(假设这两人登录后一直再使用)
// 当 C 用户登录时 A 用户就会被挤掉 当其再使用时就会收到这条提示
@Component
public class MySessionExpiredHandler implements SessionInformationExpiredStrategy {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
ResponseUtils.responseJson(event.getResponse(), APIResponse.failure("您已被迫下线,请重新登录!"));
}
}
登出的主要逻辑是在用户登出后清除用户信息,如 session、SecurityContext 等信息,以及配置登出成功后的处理器。
// 在 SecurityConfig 中配置登出相关
httpSecurity.logout()
.logoutUrl("/admin/user/logout") // 登出时调用的 url
.invalidateHttpSession(true) // 是否是 session 无效 一般为 true
.clearAuthentication(true) // 是否清除 authentication 一般为 true
.addLogoutHandler(logoutHandler) // 登出处理器 这里可以执行一些登出时要执行的自定义操作
.logoutSuccessHandler(logoutSuccessHandler); // 登出成功后的处理器
// 自定义 MyLogoutHandler 类实现 LogouHandler 接口
// 其主要作用是执行登出时的自定义操作
// 这里主要是针对分布式权限管理的操作
@Component
@RequiredArgsConstructor
public class MyLogoutHandler implements LogoutHandler {
private final MySecurityContextRepository securityContextRepository;
private final SessionRegistry sessionRegistry;
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
sessionRegistry.removeSessionInformation(request.getRequestedSessionId());
securityContextRepository.clearContext(request);
}
}
// 自定义 MyLogoutSuccessHandler 类实现 LogoutSuccessHandler 接口
// 其主要作用是执行登出成功后的操作 如返回一个 json 提示登出成功
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
ResponseUtils.responseJson(response, APIResponse.success("登出成功!"));
}
}
分布式配置主要是指在分布式环境下 spring security 的配置,一般是 SecurityContext 的存储问题以及 session 存储问题。
在单机情况下 SecurityContext(即用户登录后的信息)是存储在 http session 中的,而 session 是存储在内存中的,假设用户登录时访问的是机器 A,于是该用户的登录信息就存储在了 机器 A 的内存中,假设当用户发起下一个请求该请求落在了机器 B 上,那必然 GG。所以,分布式环境下针对 SecurityContext 而言主要是将其共享起来,多个服务器共享一套数据,就可。一般是使用 redis。
对于 session 而言,其默认是也是维护在内存中的,用了两个 map 来存储,所以当配置了 SessionManagement 时,就需要共享 session,如当你配置了踢人下线时。一般也是使用 redis。注:这里的 session 的作用和上面的不一样,这里的是用来存储多个用户同时在线所对应的 session 的,而上面的是用来存储当户登录后的信息的。
spring security 中提供的 SecurityContext 的管理接口是 SecurityContextRepository,所以我们需要自定义实现它;同理,提供的 session 的管理接口是 SessionRegistry,所以我们也要实现它。
// 自定义 MySecurityContextRepository 类实现 SecurityContextRepository 接口
@Slf4j
@Component
@RequiredArgsConstructor
public class MySecurityContextRepository implements SecurityContextRepository {
// string key: account value: context(SecurityContext)
private static final String ACCOUNT_CONTEXT_PREFIX = "security:account:context:";
// hash hashKey: session value: account
private static final String ACCOUNT_SESSIONS_PREFIX = "security:account:sessions";
private final AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
// 这里是自定义注入的 ObjectMapper 为什么要自定义后续会讲到
@Resource(name = "springSecurityObjectMapper")
private final ObjectMapper springSecurityObjectMapper;
@Override // 加载 context(注:HttpRequestResponseHolder 已经被启用了)
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
return getSecurityContext(requestResponseHolder.getRequest());
}
@Override // 加载 context
public Supplier<SecurityContext> loadContext(HttpServletRequest request) {
return () -> getSecurityContext(request);
}
@Override // 保存 context
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
Authentication authentication = context.getAuthentication();
if (authentication == null || this.trustResolver.isAnonymous(authentication)) {
return;
}
UserEntity userEntity = (UserEntity) authentication.getPrincipal();
String account = userEntity.getUsername();
String sessionId = request.getRequestedSessionId();
RedisUtils.addValue(ACCOUNT_CONTEXT_PREFIX + account, context, true);
RedisUtils.addHashValue(ACCOUNT_SESSIONS_PREFIX, sessionId, account, true);
}
@Override // 是否包含某个 session
public boolean containsContext(HttpServletRequest request) {
String sessionId = request.getRequestedSessionId();
return RedisUtils.containHashKey(ACCOUNT_SESSIONS_PREFIX, sessionId, true);
}
// 清除 context 登出时会用到
public void clearContext(HttpServletRequest request) {
String sessionId = request.getRequestedSessionId();
if (RedisUtils.containHashKey(ACCOUNT_SESSIONS_PREFIX, sessionId, true)) {
RedisUtils.deleteHashKey(ACCOUNT_SESSIONS_PREFIX, sessionId, true);
}
}
// 获取 security context
private SecurityContext getSecurityContext(HttpServletRequest request) {
String sessionId = request.getRequestedSessionId();
SecurityContext emptyContext = SecurityContextHolder.createEmptyContext();
Object o = RedisUtils.getHashValue(ACCOUNT_SESSIONS_PREFIX, sessionId, true);
if (o == null) {
return emptyContext;
}
try {
Object result = RedisUtils.getValue(ACCOUNT_CONTEXT_PREFIX + o, true);
SecurityContext securityContext = springSecurityObjectMapper.convertValue(result, SecurityContext.class);
return securityContext == null ? emptyContext : securityContext;
} catch (Exception e) {
log.error("Failed to convert Map to Object!", e);
return emptyContext;
}
}
}
// 自定义 MySessionRegistry 类实现 SessionRegistry
@Slf4j
@Component
@RequiredArgsConstructor
public class MySessionRegistry implements SessionRegistry {
// hash hashKey: Principal value: sessionIds
private static final String PRINCIPAL_SESSIONS_PREFIX = "security:session:principal";
// hash hashKey: sessionId value: SessionInformation
public static final String SESSION_INFORMATION_PREFIX = "security:session:information";
@Override // 获取所有登录主体(即用户)
public List<Object> getAllPrincipals() {
return new ArrayList<>(RedisUtils.getHashKeys(PRINCIPAL_SESSIONS_PREFIX, false));
}
@Override // 获取登录某个账号的所有 session 信息
public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
if (principal == null) {
return new ArrayList<>();
}
Set<String> sessionIds = getSessionIds(principal);
if (CollectionUtils.isEmpty(sessionIds)) {
return new ArrayList<>();
}
List<SessionInformation> list = new ArrayList<>();
for (String sessionId : sessionIds) {
SessionInformation sessionInformation = getSessionInformation(sessionId);
if (sessionInformation == null) {
continue;
}
if (includeExpiredSessions || !sessionInformation.isExpired()) {
list.add(sessionInformation);
}
}
return list;
}
@Override // 根据 sessionId 获取 SessionInformation
public SessionInformation getSessionInformation(String sessionId) {
return getSession(sessionId);
}
@Override // 刷新某个 session 的最后一次请求时间
public void refreshLastRequest(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
SessionInformation sessionInformation = getSessionInformation(sessionId);
if (sessionInformation != null) {
sessionInformation.refreshLastRequest();
RedisUtils.addHashValue(SESSION_INFORMATION_PREFIX, sessionId, sessionInformation, false);
}
}
@Override // 注册一个新 session
public void registerNewSession(String sessionId, Object principal) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
Assert.notNull(principal, "Principal required as per interface contract");
if (getSessionInformation(sessionId) != null) {
removeSessionInformation(sessionId);
}
Set<String> sessionIds = getSessionIds(principal);
sessionIds.add(sessionId);
RedisUtils.addHashValue(PRINCIPAL_SESSIONS_PREFIX, principal, sessionIds, false);
// 注:这里使用的是 SessionInformation 的扩展类 即MySessionInformation
RedisUtils.addHashValue(SESSION_INFORMATION_PREFIX, sessionId, new MySessionInformation(principal, sessionId, new Date(), false),
false);
}
@Override // 移除一个 session 信息
public void removeSessionInformation(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
SessionInformation sessionInformation = getSessionInformation(sessionId);
if (sessionInformation == null) {
return;
}
RedisUtils.deleteHashKey(SESSION_INFORMATION_PREFIX, sessionId, false);
if (sessionInformation.getPrincipal() == null) {
return;
}
Set<String> sessionIds = getSessionIds(sessionInformation.getPrincipal());
if (CollectionUtils.isEmpty(sessionIds)) {
return;
}
sessionIds.remove(sessionId);
RedisUtils.addHashValue(PRINCIPAL_SESSIONS_PREFIX, sessionInformation.getPrincipal(), sessionIds, false);
}
// 根据 principal 获取 sessionIds
private Set<String> getSessionIds(Object principal) {
Object o = RedisUtils.getHashValue(PRINCIPAL_SESSIONS_PREFIX, principal, false);
if (o == null) {
return new HashSet<>();
} else {
return new HashSet<>((List<String>) o);
}
}
// 根据 sessionId 获取 session information
public static MySessionInformation getSession(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
try {
Object o = RedisUtils.getHashValue(SESSION_INFORMATION_PREFIX, sessionId, false);
if (o == null) {
return null;
}
Map<String, Object> map = (Map<String, Object>) o;
Date lastRequest = new Date((Long) map.get("lastRequest"));
Object principal = map.get("principal");
boolean expireState = (boolean) map.get("expireState");
return new MySessionInformation(principal, sessionId, lastRequest, expireState);
} catch (Exception e) {
log.error("Failed to convert Map to Object!", e);
return null;
}
}
// 更新 session state
public static void updateSessionState(SessionInformation sessionInformation) {
RedisUtils.addHashValue(MySessionRegistry.SESSION_INFORMATION_PREFIX, sessionInformation.getSessionId(), sessionInformation,
false);
}
}
// 自定义 MySessionInformation 类继承 SessionInformation
public class MySessionInformation extends SessionInformation {
private static final long serialVersionUID = 7136764596787584258L;
// 过期状态 父类中也有一个过期状态 expired 但它只能读和设置为 true 不能设置为 false 所以自定义了一个属性
private boolean expireState;
public MySessionInformation(Object principal, String sessionId, Date lastRequest, boolean expireState) {
super(principal, sessionId, lastRequest);
this.expireState = expireState;
}
@Override // 重写 expireNow 方法 其作用主要是使 session 失效
public void expireNow() {
String sessionId = getSessionId();
MySessionInformation sessionInformation = MySessionRegistry.getSession(sessionId);
if (sessionInformation != null) {
super.expireNow();
sessionInformation.setExpireState(true);
MySessionRegistry.updateSessionState(sessionInformation);
}
}
@Override
public boolean isExpired() {
return this.expireState;
}
public boolean isExpireState() {
return expireState;
}
public void setExpireState(boolean expireState) {
this.expireState = expireState;
}
}
由于反序列化容易被攻击,所以 spring security 针对存储敏感信息的类设计了一个反序列化类的白名单,即只有加入这个白名单的类才可被反序列化,其中 Authentication 接口、UserDetails 接口的实现类及一些权限相关的类若要反序列化则必须得加入白名单才可反序列化,因为我们自定义实现了 UserDetails 接口,所以需要将其加入到白名单中,而加入方式有两种,第一种是自定义反序列化方式,第二种是使用 jackson 相关注解。(其是根据异常信息来看还有第三种,那就是直接加入白名单,但维护白名单的集合是私有的,好像不太可)。而自定义的反序列化方式怎么和白名单关联起来,请看下文。
// 在 UserEntity 类中以内部类的方式实现 UserEntity 的反序列化方式
public static class UserEntityDeserializer extends JsonDeserializer<UserEntity> {
@Override
public UserEntity deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
ObjectMapper mapper = (ObjectMapper) jsonParser.getCodec();
JsonNode jsonNode = mapper.readTree(jsonParser);
long id = getJsonNode(jsonNode, "id").asLong();
String account = getJsonNode(jsonNode, "account").asText();
String password = getJsonNode(jsonNode, "password").asText("");
int accountType = getJsonNode(jsonNode, "accountType").asInt();
String nickName = getJsonNode(jsonNode, "nickName").asText();
int accountExpireState = getJsonNode(jsonNode, "accountExpireState").asInt();
int passwordExpireState = getJsonNode(jsonNode, "passwordExpireState").asInt();
int lockState = getJsonNode(jsonNode, "lockState").asInt();
int enableState = getJsonNode(jsonNode, "enableState").asInt();
List<String> roleList = mapper.convertValue(getJsonNode(jsonNode, "roleList"), new TypeReference<List<String>>() {});
return UserEntity.builder()
.id(id)
.account(account)
.password(password)
.accountType(accountType)
.nickName(nickName)
.accountExpireState(accountExpireState)
.passwordExpireState(passwordExpireState)
.lockState(lockState)
.enableState(enableState)
.roleList(roleList)
.build();
}
private JsonNode getJsonNode(JsonNode jsonNode, String field) {
return jsonNode.has(field) ? jsonNode.get(field) : MissingNode.getInstance();
}
}
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class")
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonDeserialize(using = UserEntityDeserializer.class)
public abstract static class UserEntityMixin {
}
在 spring boot 框架中,帮我们自动注入了一个 ObjectMapper bean,按理来说直接使用就可以,那为什么还要自定义注入呢?,请看以下代码(手动狗头)。
// 自定义注入 ObjectMapper bean
// 注入 redistemplate bean 时 value 的序列化和反序列化器需要将注入的这个 objectMapper 设置进去
@Bean("springSecurityObjectMapper")
public ObjectMapper springSecurityObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
// 因为放入 redis 的 SecurityContext 中的 Authentication 接口的相关实现类没有无参构造函数 会导致反序列化失败
// 所以需要借助 security 中的相关 module
objectMapper.registerModules(SecurityJackson2Modules.getModules(getClass().getClassLoader()));
// 由于反序列化经常出现漏洞 故 spring security 增加了反序列化类的白名单 即只有加入此白名单的类才可被反序列化
// 而放入 redis 中的 SecurityContext 中的 authentication 中的 principal 对象实际上是我们自定义实现的用户类(UserEntity)
// 该类需要被反序列化 但却没被加入白名单 所以需要将其加入白名单
// 加入白名单有两种方式 即加入 mixIn 和 使用 jackson 相关注解(以下使用方式一)
objectMapper.addMixIn(UserEntity.class, UserEntity.UserEntityMixin.class);
return objectMapper;
}
spring 给我们提供了一个组件,spring session,是专门用来实现分布式 session 共享的,根本不用实现什么 repostory、registry,直接引入依赖,一个注解就成。(泪目!!!)。
<dependency>
<groupId>org.springframework.sessiongroupId>
<artifactId>spring-session-data-redisartifactId>
dependency>
// 在启动类上加上开启 spring session 的注解 @EnableRedisHttpSession
// 替换掉 spring session data redis 中的序列化和反序列化器
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer(springSecurityObjectMapper);
}
// over!!!
// SecurityConfig
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity,
AuthenticationSuccessHandler authenticationSuccessHandler,
AuthenticationFailureHandler authenticationFailureHandler,
AuthenticationEntryPoint authenticationEntryPoint,
SessionRegistry sessionRegistry,
SessionInformationExpiredStrategy sessionExpiredHandler,
SecurityContextRepository securityContextRepository,
AccessDeniedHandler accessDeniedHandler,
SmsAuthenticationConfigurer<HttpSecurity> smsAuthenticationConfigurer,
ObjectPostProcessor<FilterSecurityInterceptor> objectPostProcessor,
LogoutHandler logoutHandler,
LogoutSuccessHandler logoutSuccessHandler) throws Exception {
// csrf
httpSecurity.csrf()
.disable();
// 用户名密码认证
httpSecurity.formLogin()
.loginProcessingUrl("/admin/user/login")
.usernameParameter("account")
.passwordParameter("password")
.successHandler(authenticationSuccessHandler) // 认证成功处理器
.failureHandler(authenticationFailureHandler); // 认证失败处理器
// 短信验证码认证
httpSecurity.apply(smsAuthenticationConfigurer)
.loginProcessingUrl("/admin/sms/login")
.setSuccessHandler(authenticationSuccessHandler)
.setFailureHandler(authenticationFailureHandler);
// session 管理
httpSecurity.sessionManagement()
.maximumSessions(2)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry) // 单机不需要、使用 spring session 也不需要
.expiredSessionStrategy(sessionExpiredHandler);
// SecurityContext 仓储
httpSecurity.securityContext()
.requireExplicitSave(true) // spring security 中关于 SecurityContext 的过滤器有两个 旧的被启用了 设置为 true 表示使用新的
.securityContextRepository(securityContextRepository); // 单机不需要、使用 spring session 也不需要
// 认证入口
httpSecurity.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint);
// FilterSecurityInterceptor expression 授权
// 注:AuthorizationFilter 授权、FilterSecurityInterceptor url 授权、FilterSecurityInterceptor expression 授权 只能三选一
httpSecurity.authorizeRequests()
.withObjectPostProcessor(objectPostProcessor) // FilterSecurityInterceptor 的后置处理器
.antMatchers("/admin/user/login", "/admin/sms/login")
.permitAll();
// 授权失败处理器
httpSecurity.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler);
// 登出
httpSecurity.logout()
.logoutUrl("/admin/user/logout") // 登出 url
.invalidateHttpSession(true) // 是否使 session 失效
.clearAuthentication(true) // 是否清除 authentication
.addLogoutHandler(logoutHandler) // 登出处理器
.logoutSuccessHandler(logoutSuccessHandler); // 登出成功处理器
// AuthorizationFilter 授权
/*httpSecurity.authorizeHttpRequests()
.antMatchers("/user/**", "/perm/**", "/role/**")
.hasAnyRole("SUPER-ADMIN", "ADMIN")
.antMatchers("/web/**")
.hasAnyRole("WEB");*/
// FilterSecurityInterceptor url 授权
/*httpSecurity.apply(new UrlAuthorizationConfigurer<>(httpSecurity.getSharedObject(ApplicationContext.class)))
.withObjectPostProcessor(new ObjectPostProcessor() {
@Override
public O postProcess(O object) {
object.setSecurityMetadataSource(securityMetadataSource);
return object;
}
})
.getRegistry()
.antMatchers("/admin/user/login", "/admin/sms/login")
.hasAnyRole("LOGIN_ROLE");*/
return httpSecurity.build();
}
@Bean("springSecurityObjectMapper")
public ObjectMapper springSecurityObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
// 因为放入 redis 的 SecurityContext 中的 Authentication 接口的相关实现类没有无参构造函数 会导致反序列化失败
// 所以需要借助 security 中的相关 module
objectMapper.registerModules(SecurityJackson2Modules.getModules(getClass().getClassLoader()));
// 由于反序列化经常出现漏洞 故 spring security 增加了反序列化类的白名单 即只有加入此白名单的类才可被反序列化
// 而放入 redis 中的 SecurityContext 中的 authentication 中的 principal 对象实际上是我们自定义实现的用户类(UserEntity)
// 该类需要被反序列化 但却没被加入白名单 所以需要将其加入白名单
// 加入白名单有两种方式 即加入 mixIn 和 使用 jackson 相关注解(以下使用方式一)
objectMapper.addMixIn(UserEntity.class, UserEntity.UserEntityMixin.class);
return objectMapper;
}
// 注入的这个 objectMapper 是响应前端是使用
@Bean("objectMapper")
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
把少年留给琴弦,把深情留给琴键!