Spring Security整合三方登录(Auth)

Spring Security & Session实现企业级用户登录及权限控制

1.需求背景

OCP与ODC部署到内部与外部需要具有成熟的用户管理系统,内部需要接入buc用户体系,外部输出需要对接金融云和阿里云三方登录解决方案。原有的OCP1.0系统中用户管理模块是自己实现的,用户信息从Browser传递到Server过程中明文暴露,Cookie或Session泄露容易发生CSRF。1.0系统中权限管理比较混乱,一个角色多达十几个权限,权限系统由前端来控制,不能实现API级别的精确权限控制。

Spring Framework4.x开始提供对Spring Securiy支持,这是一个基于Spring AOP和Servlet过滤器的安全框架。它提供全面的安全性解决方案,同时在Web请求级和方法调用级处理身份确认和授权。

Spring Session是分布式Session的解决方案,在单点式应用中,Session是由tomcat管理的,存在于tomcat的内存中,当我们为了解决分布式场景中的Session共享问题时,引入了redis,其共享内存,以及支持key自动过期的特性,非常契合Session的特性,我们在企业开发中最常用的也就是这种模式。Spring Session通过AOP的方式,将HttpSession持久化到Redis或MySql中,实现分布式部署时对Session的共享。

本文解决的目标:

  • Spring Security对接Buc、金融云三方登录
  • Spring Security实现对已登录的用户进行权限管理
  • Spring Session共享Session,解决分布式部署“单点”登录

2.方案流程

刚开始接触Spring Security时新的概念特别多,容易找不到着手点。简单来讲,Spring Security提供了一系列的规范接口,需要按要求在这些接口下面实现自己的业务逻辑,Spring Security就会用户的登录及权限动作进行管理。

核心接口
UserDetails:Spring Security用户信息接口
UserDetailsService:其中loadUserByUsername方法用户通过username来从内存或DB中查询出用户信息,在这个方法中可以自定义权限信息
OncePerRequestFilter:过滤登录请求,确保只能通过一次,三方登录时可以在此写入三方登录逻辑
AuthenticationProvider:验证用户登录是否合法
WebSecurityConfigurerAdapter:配置类

Http请求的调用路径如下:

Http -> OncePerRequestFilter -> UserDetailsService -> AuthenticationProvider -> success -> Controller -> Browser

Spring Security Auth项目可以实现标准的Auth2.0协议的登录,支持Github/FaceBook,仅需在application.properties中写入配置即可,使用起来更为简便。但是Buc和金融云三方登录均未实现标准的Auth2.0协议,故这种方案并不适用。

Spring Session支持持久化到Redis或者MySql。Spring Boot工程由于具有自动装配的特性,使用起来极为简单。仅需开启@EnableRedisHttpSession或者@EnableJdbcHttpSession注解即可。Redis支持任意对象的持久化,所以只要配置好Redis连接信息就可以。选择持久化至MySql,还需建立两张表存储session。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CREATE TABLE `SPRING_SESSION` (
`SESSION_ID` char(36) NOT NULL DEFAULT '',
`CREATION_TIME` bigint(20) NOT NULL,
`LAST_ACCESS_TIME` bigint(20) NOT NULL,
`MAX_INACTIVE_INTERVAL` int(11) NOT NULL,
`PRINCIPAL_NAME` varchar(100) DEFAULT NULL,
PRIMARY KEY (`SESSION_ID`) USING BTREE,
KEY `SPRING_SESSION_IX1` (`LAST_ACCESS_TIME`) USING BTREE
) DEFAULT CHARSET=utf8mb4;

CREATE TABLE `SPRING_SESSION_ATTRIBUTES` (
`SESSION_ID` char(36) NOT NULL DEFAULT '',
`ATTRIBUTE_NAME` varchar(100) NOT NULL DEFAULT '',
`ATTRIBUTE_BYTES` blob,
PRIMARY KEY (`SESSION_ID`,`ATTRIBUTE_NAME`),
KEY `SPRING_SESSION_ATTRIBUTES_IX1` (`SESSION_ID`) USING BTREE,
CONSTRAINT `SPRING_SESSION_ATTRIBUTES_ibfk_1` FOREIGN KEY (`SESSION_ID`) REFERENCES `SPRING_SESSION` (`SESSION_ID`) ON DELETE CASCADE
) DEFAULT CHARSET=utf8mb4;

3.三方包依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- buc -->
<dependency>
<groupId>com.alibaba.platform.shared</groupId>
<artifactId>buc.sso.client</artifactId>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
<exclusion>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
</exclusion>
</exclusions>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>

4.代码Demo

Spring Security

重载UserDatails接口用来构建Spring Security标准的用户信息类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class OcpUserDetails implements UserDetails {

//自定义的用户类
private OcpUser user;

//用户权限信息
private Collection<? extends GrantedAuthority> authorities;

public OcpUserDetails(OcpUser user, Collection<? extends GrantedAuthority> authorities) {
super();
this.user = user;
this.authorities = authorities;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}

@Override
public String getPassword() {
return this.user.getPassword();
}

@Override
public String getUsername() {
return this.user.getEmail();
}

//省略部分@Override
}

UserDetailsService,重载loadUserByUsername,将DB中的用户信息与权限信息放入Spring Security上下文中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Service
public class OcpUserDetailService implements UserDetailsService {

@Autowired
private OcpUserService ocpUserService;

@Override
public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
UserDetails userDetails = null;
try {
//通过username从DB中查到当前用户信息
OcpUser user = ocpUserService.findUserByName(name);
if(null != user){
List<GrantedAuthority> authorities = new ArrayList<>();
/**
* 装入用户的权限,如果用户的角色是ROLE,在Spring Security中对应的权限是ROlE_USER
*/
SimpleGrantedAuthority grant = new SimpleGrantedAuthority("ROLE_USER");
authorities.add(grant);
//封装自定义UserDetails类
userDetails = new OcpUserDetails(user, ListUtils.distinct(authorities));
}
else {
throw new UsernameNotFoundException("该用户不存在!");
}
} catch (Exception e) {
LOGGER.error(e.getMessage());
}
return userDetails;
}
}

重载OncePerRequestFilter接口,自定义登录拦截逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
public class AuthTokenFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

//buc登录时可以获取当前登录的信息
BucSSOUser bucSSOUser = SimpleUserUtil.getBucSSOUser(request);

//Spring Security上下文中的登录信息,判断Spring Security中是否含有登录信息
Authentication auth = SecurityContextHolder.getContext().getAuthentication();

if (bucSSOUser != null) {

//TODO doLoginSuccess2DB

String userName = bucSSOUser.getLoginName();
Authentication authentication = new UsernamePasswordAuthenticationToken(userName, "admin");
//将用户登录信息放入Spring Security上下文中
SecurityContextHolder.getContext().setAuthentication(authentication);
}

filterChain.doFilter(request, response);
}
}

Spring Security验证是否登录逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Service
public class OcpSecurityProvider implements AuthenticationProvider {

@Autowired
@Qualifier("ocpUserDetailService")
private UserDetailsService userDetailsService;

public OcpSecurityProvider(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
String username = token.getName();
UserDetails userDetails = null;

if (username != null) {
userDetails = userDetailsService.loadUserByUsername(username);
}
LOGGER.info("login success: {}", userDetails);

if (userDetails == null) {
throw new UsernameNotFoundException("用户名/密码无效");
} else if (!userDetails.isEnabled()) {
LOGGER.error("{} 用户已被禁用", userDetails.getUsername());
throw new DisabledException("用户已被禁用");
} else if (!userDetails.isAccountNonExpired()) {
LOGGER.error("{} 账号已过期", userDetails.getUsername());
throw new LockedException("账号已过期");
} else if (!userDetails.isAccountNonLocked()) {
LOGGER.error("{} 账号已被锁定", userDetails.getUsername());
throw new LockedException("账号已被锁定");
} else if (!userDetails.isCredentialsNonExpired()) {
LOGGER.error("{} 凭证已过期", userDetails.getUsername());
throw new LockedException("凭证已过期");
}

String password = userDetails.getPassword();
//与authentication里面的credentials相比较
//OncePerRequestFilter中装入的
if (!password.equals(token.getCredentials())) {
throw new BadCredentialsException("Invalid username/password");
}
//授权
return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());
}
}

WebConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@Configuration
@EnableWebSecurity
//开启@PreAuthorize对RequestMapping进行权限控制
@EnableGlobalMethodSecurity(prePostEnabled = true)
//开启Spring Session
@EnableJdbcHttpSession(maxInactiveIntervalInSeconds = 3600)
public class OcpWebSecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private UserDetailsService userDetailsService;

@Autowired
private AuthenticationProvider securityProvider;

@Override
protected UserDetailsService userDetailsService() {
return this.userDetailsService;
}

@Autowired
private AuthTokenFilter authTokenFilter;

/**
* 自定义登录权限认证
*
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(securityProvider);
}

/**
* 静态资源忽略拦截
*
* @param web
* @throws Exception
*/
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/**.css");
web.ignoring().antMatchers("/**.js");
web.ignoring().antMatchers("/**.html");
web.ignoring().antMatchers("/**.jpg");
web.ignoring().antMatchers("/**.png");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/test/**").hasRole("USER") //需要USER角色
.antMatchers("/auth/**").permitAll() //无需登录即可访问
.anyRequest().authenticated() //只要登录即可访问
.and()
.addFilterAfter(authTokenFilter, SecurityContextHolderAwareRequestFilter.class)
.formLogin().disable() //关闭表单登录提交用户登录信息,用于本地登录
.formLogin().loginPage("/login") //没有登录时跳转地址
.logout().permitAll()
.and()
.httpBasic().disable()
.csrf().disable(); //关闭禁止表单跨域提交
}

@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("admin").password("admin").roles("USER");
}
}
表达式 描述
hasRole([role]) 当前用户是否拥有指定角色
hasAnyRole([role1,role2]) 多个角色是一个以逗号进行分隔的字符串。如果当前用户拥有指定角色中的任意一个则返回true
hasAuthority([auth]) 等同于hasRole
hasAnyAuthority([auth1,auth2]) 等同于hasAnyRole
Principle 代表当前用户的principle对象
authentication 直接从SecurityContext获取的当前Authentication对象
permitAll 总是返回true,表示允许所有的
denyAll 总是返回false,表示拒绝所有的
isAnonymous() 当前用户是否是一个匿名用户
isRememberMe() 表示当前用户是否是通过Remember-Me自动登录的
isAuthenticated() 表示当前用户是否已经登录认证成功了
isFullyAuthenticated() 如果当前用户既不是一个匿名用户,同时又不是通过Remember-Me自动登录的,则返回true

本地登录时,表单将username与password POST提交至 /login

除了在WebSecurityConfigurerAdapter配置URL级别的权限控制之外,还可以通过注解,实现方法级安全控制

注解 描述
@PreAuthorize 在方法调用之前,基于SpEL表达式的计算结果来限制对方法的访问
@PostAuthorize 允许方法调用,但是如果SpEL表达式的计算结果为false,将抛出一个安全性异常
@PreFilter 允许方法调用,但必须按照表达式来过滤方法的结果
@PostFilter 允许方法调用,但必须在进入方法之前过滤输入值

SpEL可以实现更灵活的安全控制逻辑,比如某个用户只能在周二访问,非付费用户输入的字符字数不超过140个字等,但是安全注解不宜过于智能与灵活。

buc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@SuppressWarnings("SpringJavaAutowiringInspection")
@Configuration
public class BucAutoConfiguration {

@ConditionalOnExpression(value = "T(com.alipay.ocp.security.models.AuthEnvEnum).BUC.equals(T(com.alipay.ocp.security.SecurityProperties).AUTH_ENV)")
@Bean
public FilterRegistrationBean bucSsoFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new SSOFilter());
registration.addUrlPatterns("/*");
registration.addInitParameter("APP_NAME", SecurityProperties.BUC_APP_NAME);//修改app_name为在接入时填写的应用名
registration.addInitParameter("SSO_CALLBACK_CLASS", "com.alibaba.buc.sso.client.handler.impl.BucSSOCallBack");//回调处理类
registration.addInitParameter("SSO_SERVER_URL", SecurityProperties.BUC_SERVER_URL);//SSO服务器地址
registration.addInitParameter("RETURN_USER_EXTEND_INFO", "true");//返回完整的用户对象信息
registration.addInitParameter("CLIENT_KEY", SecurityProperties.BUC_CLIENT_KEY);//客户端密钥
if (SecurityProperties.BUC_APP_CODE != null) {
registration.addInitParameter("APP_CODE", SecurityProperties.BUC_APP_CODE);//应用标识
}
registration.addInitParameter("EXCLUSIONS", SecurityProperties.BUC_EXCLUSIONS);//排除规则列表;不走sso验证的requestUri(应用请根据实际情况修改),uri间用半角逗号隔开。*通配任意多个字符,?通配任意单个字符
//要比spring security的filter要高
registration.setOrder(FilterRegistrationBean.REQUEST_WRAPPER_FILTER_MAX_ORDER - 200);
registration.setName("bucSsoFilter");
return registration;
}
}

获取session中bucy用户信息
BucSSOUser bucSSOUser = SimpleUserUtil.getBucSSOUser(request);

buc接入文档
http://gitlab.alibaba-inc.com/buc/sso/wikis/home
http://gitlab.alibaba-inc.com/buc/sso/wikis/buc-springboot-starter-guide

金融云

接入文档
https://lark.alipay.com/antcloud-platform/antcloud/antcloud-login

5.结语

  • 使用Spring Security中很容易产生用户是否登录以及登录后的信息在哪里查看的困惑。

    1
    2
    SecurityContext ctx = SecurityContextHolder.getContext();
    Authentication auth = ctx.getAuthentication();

    可以用上面提供类从Session中获取用户的登录信息,既可以在OncePerRequestFilter中判断用户是否登录,也可以用在Controller中获取当前session中的用户信息。

  • Spring Security中部分功能封装过于细致,这种高度智能化的封装带来的好处是便于本地及Demo级开发,导致并不适应自定义的灵活需求。比如HttpBasic,自定义登录页面和jdbcAuthentication。

参考文献:

Spring Security:
https://www.jianshu.com/p/08cc28921fd0
https://segmentfault.com/a/1190000013057238

Spring Session:
http://blog.didispace.com/spring-session-xjf-2/