基于Spring Security + OAuth2 的SSO单点登录(客户端)

单点登录(服务端):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;

    @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(角色组),只要有一个匹配上则允许访问,没有匹配上则表示没有权限。

4.2 自定义 FilterInvocationSecurityMetadataSource 的配置

/**
 * @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的访问限制。

4.3 自定义 AccessDecisionManager的配置

@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;
    }
}

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;
	
	@Override
	public 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 {

	@Override
	public 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: 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

6. Controller

@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才能在服务端拿到用户数据,否则服务端无法识别,将标识此次请求为未登录。

测试

单点登录测试:
基于Spring Security + OAuth2 的SSO单点登录(客户端)_第1张图片
单点退出测试:

项目源码:单点登录服务端 、单点登录客户端

参考博客

https://www.baeldung.com/sso-spring-security-oauth2
https://www.linzepeng.com/2018/10/31/sso-note1/

你可能感兴趣的:(Java,spring-security)