单点登录(服务端):https://blog.csdn.net/qq_34997906/article/details/97007709
为什么要把客户端单独拿出来写呢 ?
博主也参考了网上很多写单点登录的,但基本上都是大同小异,在客户端的自身权限校验 和 单点退出 均未做处理,显然并不满足实际的业务开发。
客户端登录:用户访问客户端,客户端 security 发现此请求的用户未登录,于是将请求重定向到服务端认证,服务端检测到此请求的用户未登录,则将此请求跳转到服务端提供的登录页面(前后端分离则是前端登录地址,否则为服务端内置的登录页面),登录成功后,服务端将系统的权限信息(为了减轻服务端的访问压力)和用户的特有标志(如用户名,记录此用户的登录状态)存入redis,然后服务端会跳回到用户第一次访问客户端的页面。
客户端URL的拦截:每次请求到来时,客户端都去Redis中去取认证中心存入的权限信息和用户特有的登录标志,权限信息只是为了匹配此登录用户是否有权利访问此接口,用户的特有标志则是为了检测该用户是否在其他客户端退出了,如若没有取到,则重定向到服务端的登录页面。
<!-- 集成 SSO 依赖 -->
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.3.RELEASE</version>
</dependency>
<!-- redis 所需 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.1.4.RELEASE</version>
</dependency>
@Configuration
@EnableOAuth2Sso
public class ClientWebsecurityConfigurer extends WebSecurityConfigurerAdapter {
@Autowired
@Qualifier("urlFilterInvocationSecurityMetadataSource")
UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;
@Autowired
@Qualifier("urlAccessDecisionManager")
AccessDecisionManager urlAccessDecisionManager;
@Autowired
@Qualifier("securityAccessDeniedHandler")
private AccessDeniedHandler securityAccessDeniedHandler;
@Autowired
@Qualifier("securityAuthenticationEntryPoint")
private AuthenticationEntryPoint securityAuthenticationEntryPoint;
@Value("${auth-server}")
public String auth_server;
@Bean
public RestTemplate restTemplate(){
return new RestTemplate();
}
/**
* 放行静态资源
*/
@Override
public void configure(WebSecurity web) {
web.ignoring().antMatchers(
"/css/**",
"/js/**",
"/favicon.ico",
"/static/**",
"/error");
}
@Override
public void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/login").permitAll()
.anyRequest().authenticated()
.withObjectPostProcessor(urlObjectPostProcessor());
http
.exceptionHandling()
.authenticationEntryPoint(securityAuthenticationEntryPoint)
.accessDeniedHandler(securityAccessDeniedHandler);
http.
logout()
.logoutSuccessUrl(auth_server + "/logout")
.deleteCookies("JSESSIONID");
// 不加会导致退出 不支持GET方式
http.csrf().disable();
}
public ObjectPostProcessor urlObjectPostProcessor() {
return new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);
o.setAccessDecisionManager(urlAccessDecisionManager);
return o;
}
};
}
}
配置说明:
.withObjectPostProcessor(urlObjectPostProcessor());
此配置表示启用了spring-security
的自定义校验,要实现URL的自定义校验,核心就是urlFilterInvocationSecurityMetadataSource
,urlAccessDecisionManager
这两个类,第一个类主要功能是 拿到 访问 此URL所需要的GrantedAuthority
(即 需要哪些角色),第二个类主要功能是比较用户有的GrantedAuthority
(用户拥有的角色)是否包含此URL需要的GrantedAuthority
(角色组),只要有一个匹配上则允许访问,没有匹配上则表示没有权限。
/**
* @author lirong
* @ClassName: UrlFilterInvocationSecurityMetadataSource
* @Description: 获取访问此URL所需要的角色集和
* @date 2019-07-10 14:36
*/
@Component("urlFilterInvocationSecurityMetadataSource")
@Slf4j
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
@Autowired
private RedisTemplate redisTemplate;
public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
HttpServletRequest request = ((FilterInvocation) o).getHttpRequest();
// 获取Redis中用户的登录标志 判断此用户有没有在其他客户端退出
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = (String) authentication.getPrincipal();
String isLogin = (String) redisTemplate.opsForValue().get(Constant.REDIS_PERM_KEY_PREFIX + username);
if(StringUtils.isEmpty(isLogin)){
throw new AccountExpiredException("用户已在其他客户端退出");
}
// 获取此URL需要的角色集合
List<Map<String, String[]>> menuMap = (List<Map<String, String[]>>) redisTemplate.opsForValue().get(Constant.REDIS_PERM_KEY_PREFIX);
if (null != menuMap) {
for (Map<String, String[]> map : menuMap) {
for (String url : map.keySet()) {
String[] split = url.split(":");
AntPathRequestMatcher antPathMatcher = new AntPathRequestMatcher(split[0], split[1]);
if(antPathMatcher.matches(request)){
return SecurityConfig.createList(map.get(url));
}
}
}
}
// 没有匹配上的资源,都是登录访问
return SecurityConfig.createList("ROLE_LOGIN");
}
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
public boolean supports(Class<?> aClass) {
return false;
}
}
为什么返回
ROLE_LOGIN
?
ROLE_LOGIN
,见名知意,只需要登录即可访问,最后返回只是为了给系统没有纳入权限表的URL加一层校验,当然,你也可以直接返回null,这样没有匹配上的URL访问将不受security的访问限制。
@Component("urlAccessDecisionManager")
public class UrlAccessDecisionManager implements AccessDecisionManager {
@Autowired
private RedisTemplate redisTemplate;
public void decide(Authentication auth, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, AuthenticationException {
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
Iterator<ConfigAttribute> iterator = collection.iterator();
while (iterator.hasNext()) {
ConfigAttribute ca = iterator.next();
//当前请求需要的权限
String needRole = ca.getAttribute();
if ("ROLE_LOGIN".equals(needRole)) {
if (auth instanceof AnonymousAuthenticationToken) {
throw new BadCredentialsException("用户未登录");
} else {
return;
}
}
//当前用户所具有的权限
for (GrantedAuthority authority : authorities) {
if (authority.getAuthority().equals(needRole)) {
return;
}
}
}
throw new AccessDeniedException("权限不足!");
}
public boolean supports(ConfigAttribute configAttribute) {
return true;
}
public boolean supports(Class<?> aClass) {
return true;
}
}
/**
* 用户未登录时的处理
* @author lirong
* @date 2019-8-8 17:37:27
*/
@Component("securityAuthenticationEntryPoint")
@Slf4j
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Value("${auth-server}")
public String auth_server;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
log.info("尚未登录:" + authException.getMessage());
response.sendRedirect(request.getContextPath() + "/login");
}
}
配置说明
当在其他客户端退出清掉redis中数据时,此处会产生循环重定向无法跳转到登录页面的问题,我这边的处理是,当前端因为循环重定向拿不到响应时,就直接前端跳转到登录页面,重新登录,各位有更好的方式欢迎留言讨论。
/**
* 用户访问没有权限资源的处理
* @author lirong
* @date
*/
@Component("securityAccessDeniedHandler")
@Slf4j
public class SecurityAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException){
log.info(request.getRequestURL()+"没有权限");
ResponseUtils.renderJson(request, response, ResultCode.LIMITED_AUTHORITY, null);
}
}
ResponseUtils封装的是一些返回的JSON信息,包含跨域的请求头等。
auth-server: http://192.168.1.201:9999 // 认证中心的地址
server:
port: 8086
servlet:
session:
cookie:
name: UISESSION
security:
oauth2:
client:
client-id: janche
client-secret: 123456
user-authorization-uri: ${auth-server}/oauth/authorize
access-token-uri: ${auth-server}/oauth/token
resource:
jwt:
key-uri: ${auth-server}/oauth/token_key
userInfoUri: ${auth-server}/user/oauth/sso
token-info-uri: ${auth-server}/oauth/check_token
spring:
#redis
redis:
database: 0
# Redis服务器地址
host: 192.168.1.201
port: 6379
password:
timeout: 5000ms
jedis:
pool:
# 连接池中的最大连接数
max-active: 8
# 连接池中的最大空闲连接
max-idle: 8
min-idle: 0
max-wait: -1ms
@Slf4j
@RestController
public class TestController {
@Autowired
private RestTemplate restTemplate;
@Value("${auth-server}")
public String auth_server;
@GetMapping("/normal")
public String normal( ) {
return "normal permission test success !!!";
}
@GetMapping("/medium")
public String medium() {
return "mediumpermission test success !!!";
}
@GetMapping("/admin")
public String admin() {
return "admin permission test success !!!";
}
/**
* 获取认证中心的登录用户,需要获取token
*/
@GetMapping("/user")
public RestResult getLoginUser(){
String url = auth_server + "/user/oauth/sso";
String tokenValue = SecurityUtils.getJwtToken();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + tokenValue);
HttpEntity<String> entity = new HttpEntity<>(headers);
SsoUser user = restTemplate.postForObject(url, entity, SsoUser.class);
return ResultGenerator.genSuccessResult(user);
}
}
关于获取登录用户信息
因为是OAuth
客户端访问服务端,所以一定得带上服务端给颁发的access_token
才能在服务端拿到用户数据,否则服务端无法识别,将标识此次请求为未登录。
项目源码:单点登录服务端 、单点登录客户端
https://www.baeldung.com/sso-spring-security-oauth2
https://www.linzepeng.com/2018/10/31/sso-note1/