单点登录(服务端):https://blog.csdn.net/qq_34997906/article/details/97007709
1. 缘起
为什么要把客户端单独拿出来写呢 ?
博主也参考了网上很多写单点登录的,但基本上都是大同小异,在客户端的自身权限校验 和 单点退出 均未做处理,显然并不满足实际的业务开发。
2. 核心流程
客户端登录:用户访问客户端,客户端 security 发现此请求的用户未登录,于是将请求重定向到服务端认证,服务端检测到此请求的用户未登录,则将此请求跳转到服务端提供的登录页面(前后端分离则是前端登录地址,否则为服务端内置的登录页面),登录成功后,服务端将系统的权限信息(为了减轻服务端的访问压力)和用户的特有标志(如用户名,记录此用户的登录状态)存入redis,然后服务端会跳回到用户第一次访问客户端的页面。
客户端URL的拦截:每次请求到来时,客户端都去Redis中去取认证中心存入的权限信息和用户特有的登录标志,权限信息只是为了匹配此登录用户是否有权利访问此接口,用户的特有标志则是为了检测该用户是否在其他客户端退出了,如若没有取到,则重定向到服务端的登录页面。
3. 所需依赖
<!-- 集成 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>
4. 配置介绍
4.1 security 核心配置
@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;@Beanpublic RestTemplate restTemplate(){
return new RestTemplate();}/*** 放行静态资源*/@Overridepublic void configure(WebSecurity web) {
web.ignoring().antMatchers("/css/**","/js/**","/favicon.ico","/static/**","/error");}@Overridepublic 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>() {
@Overridepublic <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
(角色组),只要有一个匹配上则允许访问,没有匹配上则表示没有权限。
4.2 自定义 FilterInvocationSecurityMetadataSource 的配置
/*** @author lirong* @ClassName: UrlFilterInvocationSecurityMetadataSource* @Description: 获取访问此URL所需要的角色集和* @date 2019-07-10 14:36*/
@Component("urlFilterInvocationSecurityMetadataSource")
@Slf4j
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
@Autowiredprivate 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的访问限制。
4.3 自定义 AccessDecisionManager的配置
@Component("urlAccessDecisionManager")
public class UrlAccessDecisionManager implements AccessDecisionManager {
@Autowiredprivate 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;}
}
4.4 用户未登录时的处理
/*** 用户未登录时的处理* @author lirong* @date 2019-8-8 17:37:27*/
@Component("securityAuthenticationEntryPoint")
@Slf4j
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Value("${auth-server}")public String auth_server;@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
log.info("尚未登录:" + authException.getMessage());response.sendRedirect(request.getContextPath() + "/login");}
}
配置说明
当在其他客户端退出清掉redis中数据时,此处会产生循环重定向无法跳转到登录页面的问题,我这边的处理是,当前端因为循环重定向拿不到响应时,就直接前端跳转到登录页面,重新登录,各位有更好的方式欢迎留言讨论。
4.5 用户没有权限时的处理
/*** 用户访问没有权限资源的处理* @author lirong* @date*/
@Component("securityAccessDeniedHandler")
@Slf4j
public class SecurityAccessDeniedHandler implements AccessDeniedHandler {
@Overridepublic void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException){
log.info(request.getRequestURL()+"没有权限");ResponseUtils.renderJson(request, response, ResultCode.LIMITED_AUTHORITY, null);}
}
ResponseUtils封装的是一些返回的JSON信息,包含跨域的请求头等。
5. yml中 客户端的配置
auth-server: http://192.168.1.201:9999 // 认证中心的地址
server:port: 8086servlet:session:cookie:name: UISESSIONsecurity:oauth2:client:client-id: jancheclient-secret: 123456user-authorization-uri: ${
auth-server}/oauth/authorizeaccess-token-uri: ${
auth-server}/oauth/tokenresource:jwt:key-uri: ${
auth-server}/oauth/token_keyuserInfoUri: ${
auth-server}/user/oauth/ssotoken-info-uri: ${
auth-server}/oauth/check_tokenspring:#redisredis:database: 0# Redis服务器地址host: 192.168.1.201port: 6379password:timeout: 5000msjedis:pool:# 连接池中的最大连接数max-active: 8# 连接池中的最大空闲连接max-idle: 8min-idle: 0max-wait: -1ms
6. Controller
@Slf4j
@RestController
public class TestController {
@Autowiredprivate 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/