背景
突然接到一个需求
- 针对特定角色的人员登录web管理端,需要进行单一设备登录机制。
- 本来需求是禁止后面的人登录,但是这样有个问题,如果前一个人不登出,或直接关闭浏览器。后面人就登录不了了。只能等session过期。后来需求改成踢人
- 非管理端并不限制
初步考虑
听起来很简单,是不是SpringSecurity配置下就可以。但是实际情况确实,SpringSecurity配置的方式是全局,如这样配置,就是全局的了,无法区分权限
scala
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/token/**", "/user/**","/actuator/health","/actuator/prometheus","/health","/prometheus").permitAll()
.antMatchers("/v2/api-docs","/swagger-resources").permitAll()
.anyRequest().authenticated().and()
.logout().permitAll()
.and()
.csrf().disable()
// 注意,不能在这里控制会话登录,这里是全域生效。因为需求是只针对特定做控制,所以不能这里控制。
.sessionManagement().maximumSessions(1)
.maxSessionsPreventsLogin(false)
;
}
}
然后又找了一种方法,但这种方法不适合我们,一是我们springboot版本低不支持,二是,这种方法很难做到分端控制
kotlin
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authorization.AuthenticatedPrincipal;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
AuthorizationManager<AuthenticatedPrincipal> isAdmin = (auth, context) -> {
for (GrantedAuthority authority : auth.getAuthorities()) {
if ("ROLE_ADMIN".equals(authority.getAuthority())) {
return new AuthorizationDecision(true);
}
}
return new AuthorizationDecision(false);
};
http
.sessionManagement(session -> session
.maximumSessions((auth) -> {
if (isAdmin.check(auth, null).isGranted()) {
return -1; // 管理员可以有无限个会话
} else {
return 1; // 普通用户只能有一个会话
}
})
);
return http.build();
}
}
敲定方案
最终决定,管理端登录接口才限制、并且要区分权限来控制。登录应用只管踢人。所有其他应用集成jar,统一进行踢人处理。
上代码
非登录应用集成jar包
自定义登录控制器
scala
package com.custom.config.filter;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.web.session.ConcurrentSessionFilter;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
*
*/
public class MyConcurrentSessionFilter extends ConcurrentSessionFilter {
public MyConcurrentSessionFilter(SessionRegistry sessionRegistry, SessionInformationExpiredStrategy sessionInformationExpiredStrategy) {
super(sessionRegistry, sessionInformationExpiredStrategy);
}
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
chain.doFilter(request, response);
}
}
增加控制拦截器配置
scala
package com.custom.config;
import com.custom.config.filter.MyConcurrentSessionFilter;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.session.*;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.security.SpringSessionBackedSessionRegistry;
import javax.annotation.Resource;
/**
*
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private MyConcurrentSessionFilter concurrentSessionFilter;
private FindByIndexNameSessionRepository sessionRepository;
@Resource(name = "sessionInformationExpiredStrategy")
private SessionInformationExpiredStrategy sessionInformationExpiredStrategy;
/**
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
init();
http
.csrf().disable()
//.requestMatcher(new OAuthRequestedMatcher())
.authorizeRequests()
.antMatchers("/**").permitAll()//允许所有访问,权限控制交由filter
.anyRequest().authenticated().and()
.addFilter(concurrentSessionFilter)
;
}
public void init() {
sessionRepository = SpringBeanUtils.getBean(FindByIndexNameSessionRepository.class);
concurrentSessionFilter = new MyConcurrentSessionFilter(new SpringSessionBackedSessionRegistry(sessionRepository), sessionInformationExpiredStrategy);
}
}
踢人提示配置,要区分自然过期还是被踢掉,这个配置是必须的
java
package com.custom.config;
import com.alibaba.fastjson.JSON;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
*
*/
@Configuration
public class BaseConfig {
@Bean
SessionInformationExpiredStrategy sessionInformationExpiredStrategy() {
return new SessionInformationExpiredStrategy() {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
HttpServletResponse response = event.getResponse();
ExecuteResult<String> result = new ExecuteResult<>();
result.setData("416");
result.setError("此账号已退出,当前账号已在其他电脑登陆,一个账号不允许在多台电脑登陆,为不影响您的使用请及时修改密码,或申请个人运营账号使用。");
response.setContentType("application/json;charset=utf-8");
response.setCharacterEncoding("UTF-8");
response.setStatus(416);
PrintWriter out = response.getWriter();
out.write(JSON.toJSONString(result));
out.flush();
return ;
}
};
}
}
登录应用改动
登录接口增加如下代码,这快可以灵活根据权限去控制是否增加踢人的逻辑
kotlin
package com.custom.hsi.uap.controller;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 登录控制
*/
@RestController
@RequestMapping(value = "/user")
public class LoginController {
@Autowired
private AuthenticationManager myAuthenticationManager;//授权管理器
/**
* session并发控制器
* */
@Autowired
CompositeSessionAuthenticationStrategy compositeSessionAuthenticationStrategy;
/**
*
*/
@PostMapping(value = "/loginForS")
public ExecuteResult<Void> loginForS(HttpServletRequest request, HttpServletResponse response) {
ExecuteResult<Void> result = new ExecuteResult<>();
// 其他代码
UsernamePasswordAuthenticationToken authRequest = null;
Authentication authentication = null;
authRequest = new UsernamePasswordAuthenticationToken("userName", "password");
authentication = myAuthenticationManager.authenticate(authRequest); //调用loadUserByUsername//findByLoginName
try {
// 会话并发控制,踢掉前面的登录
// 这里后续需要优化,这个操作redis比较频繁需要优化
compositeSessionAuthenticationStrategy.onAuthentication(authentication, request, response);
} catch (SessionAuthenticationException e) {
// 这段代码是针对禁止一个用户多次登陆使用的,踢人的逻辑不会用这个代码
if (e.getMessage().contains("Maximum sessions of 1 for this principal")) {
result.setError("此账号已在其他地点登陆,禁止同时登陆多个账号,若有登陆需求,请联系服务经理申请个人相应权限的登陆账号。");
return result;
}
throw e;
} catch (Exception e) {
throw e;
}
// 其他代码
return result;
}
@Data
private final class ExecuteResult<T> {
String error;
}
}
登录应用增加配置,控制单一设备登录逻辑
java
package com.custom.security.config;
import com.alibaba.fastjson.JSON;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.security.web.authentication.session.*;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.security.SpringSessionBackedSessionRegistry;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
/**
* spring security配置类
*/
@Configuration
public class SecuritySessionConfig {
@Bean
SessionInformationExpiredStrategy sessionInformationExpiredStrategy() {
return new SessionInformationExpiredStrategy() {
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
HttpServletResponse response = event.getResponse();
ExecuteResult<String> result = new ExecuteResult<>();
result.setData("416");
// result.setError("您的账号在其他设备登录");
result.setError("此账号已在其他地点登陆,禁止同时登陆多个账号,若有登陆需求,请联系服务经理申请个人相应权限的登陆账号。");
response.setContentType("application/json;charset=utf-8");
response.setCharacterEncoding("UTF-8");
PrintWriter out = response.getWriter();
out.write(JSON.toJSONString(result));
out.flush();
return ;
}
};
}
@Bean
@DependsOn("userDetailsService")
SpringSessionBackedSessionRegistry sessionRegistry(FindByIndexNameSessionRepository sessionRepository) {
return new SpringSessionBackedSessionRegistry(sessionRepository);
}
/**
* 会话并发控制,非全域生效,需要业务自行控制
* */
@Bean
public CompositeSessionAuthenticationStrategy compositeSessionAuthenticationStrategy(RegisterSessionAuthenticationStrategy registerSessionAuthenticationStrategy, ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy, SessionFixationProtectionStrategy sessionFixationProtectionStrategy) {
List<SessionAuthenticationStrategy> list = new ArrayList<>();
list.add(registerSessionAuthenticationStrategy);
list.add(sessionFixationProtectionStrategy);
list.add(concurrentSessionControlAuthenticationStrategy);
return new CompositeSessionAuthenticationStrategy(list);
}
@Bean
public RegisterSessionAuthenticationStrategy registerSessionAuthenticationStrategy(SpringSessionBackedSessionRegistry sessionRegistry) {
return new RegisterSessionAuthenticationStrategy(sessionRegistry);
}
@Bean
public SessionFixationProtectionStrategy sessionFixationProtectionStrategy() {
return new SessionFixationProtectionStrategy();
}
/**
* 在这里控制会话登录,这里是不是全域生效。需要在具体生效的代码中,调用这个bean
* */
@Bean
public ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy(SpringSessionBackedSessionRegistry sessionRegistry) {
ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry);
concurrentSessionControlAuthenticationStrategy.setMaximumSessions(1);
// 这个属性控制,是禁止登录还是踢掉之前登录的人,true 禁止登录,false 踢人
concurrentSessionControlAuthenticationStrategy
.setExceptionIfMaximumExceeded(false);
return concurrentSessionControlAuthenticationStrategy;
}
@Data
private final class ExecuteResult<T> {
String data;
String error;
}
}
总结
登录的时候,针对特定用户,进行踢人动作。springsecurity会针对该用户的session进行打标,然后MyConcurrentSessionFilter会针对打标的请求进行特定的返回,并让session失效