SpringSecurity笔记

这篇笔记其实很早就开始写了,毕竟 SpringSecurity 现在用的非常多,而我还是半吊子水平,不过因为时间和心情问题,断断续续搞了这么久,跨度得三个月左右了,相应的这篇笔记也非常长,我就不再分篇了直接一次性怼上,基本的原理与知识点应该是覆盖全了,除了基础的 SpringSecurity 知识点,另有对 OAuth2 对支持,使用 Social 对第三方社交登陆的支持,Session 处理相关,SSO 相关的提了一下,看完之后不是问题。

这篇笔记基于慕课上的一门课,个人认为还是不错的,反反复复看了好几遍,先跟着写了一遍,当然不是完全的照抄,对基础框架做了一点升级和自己的一些处理,之后发现内容太多,决定从头再来一遍,并且整理成这篇笔记。

SpringSecurity

这一次我使用的版本是 SB2.x,集成 SpringSecurity 就不需要多说了,一个 starter 搞定,然后它的默认配置会将所有的接口保护起来,使用 http basic 来认证。

需要注意的是:security.basic.enabled 在 SB2.x 被废弃,如果需要禁用 SpringSecurity 请使用 exclude 的方式进行排除,例如:
@SpringBootApplication(exclude = {SecurityAutoConfiguration.class, ManagementWebSecurityAutoConfiguration.class})

接下来就是如何配置了,首先我们可以新建一个 java config 类,继承 WebSecurityConfigurerAdapter 这个适配器类,然后覆盖它的方法,下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected void applyPasswordAuthenticationConfig(HttpSecurity http) throws Exception {
// 使用表单登陆
http.formLogin()
// 跳转认证的页面(默认 /login)
.loginPage(SecurityConstants.DEFAULT_UN_AUTHENTICATION_URL)
// 进行认证的请求地址(UsernamePasswordAuthenticationFilter)
.loginProcessingUrl(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_FORM)
// 自定义登陆成功、失败后的处理逻辑
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
// 设置授权要求
.authorizeRequests()
.antMatchers(SecurityConstants.DEFAULT_UN_AUTHENTICATION_URL, "/user/register")
// 以上匹配不需要认证
.permitAll()
// 其他请求需要进行认证
.anyRequest()
.authenticated()
.and()
.csrf().disable();
}

接着,我们来说一下 SpringSecurity 的原理,其实也不难猜,肯定是通过 Filter 实现的,它也确实是通过一组 Filter 链来做的,首先来看一下这个图:

SpringSecurity基本原理

很显然,UsernamePasswordAuthenticationFilter 这个过滤器就是来处理表单登陆的相关请求,BasicAuthenticationFilter 那就是来处理 http basic 登陆的相关请求;例如你使用表单提交,UsernamePasswordAuthenticationFilter 会从请求中拿到用户名密码,然后去做登陆校验,如果成功则标记为已认证(通过一个过滤器链的共享变量);如果拿不到用户名密码就放行,进入到下一个过滤器再做其他方式的登陆校验。
过滤链的最后一环是 FilterSecurityInterceptor,它通过那个标志位来判断前面是否已经通过了身份认证,然后根据我们 config 中配置的规则,来控制允不允许访问;如果不过,它会根据不同的原因来抛出不同的异常。
那么,在 FilterSecurityInterceptor 前面的 ExceptionTranslationFilter 就是来接受它抛出的异常,然后根据不同类型的异常做出不同的处理,例如未登录的异常会根据前面的配置来引导用户进行登陆。
PS:其中,绿色部分是我们可以控制是否开启的。

自定义用户认证

拿到用户名密码后,如何判断是否是合法用户呢,这个需求每一个业务系统可能都不一样,所以肯定是可以自定义的,这个过程被抽象成了一个接口叫做 UserDetailsService,我们新建一个类,然后实现这个接口(只有一个需要实现的方法 loadUserByUsername),在这个方法中,我们可以拿到请求中的用户名,根据这个用户名如何获取用户信息就全靠我们自己了,使用 Mybatis 的 mapper 或者 JPA,最终只要返回一个 UserDetails 对象即可。
说起 UserDetails 对象,我们既可以用 SpringSecurity 的默认实现 User,也可以继承 User 后进行增强,以默认的 User 对象来说,它有几个构造方法,满足我们日常的账号是否禁用、是否锁定、是否过期等等需求。
它的判断逻辑也非常简单,你根据用户输入的用户名获取 UserDetails 对象并且返回,SpringSecurity 拿着你返回的这个对象中的秘密与用户输入的密码进行比对,如果错误则抛出用户名或密码错误的异常,如果这个对象的是否锁定为 true,则抛出用户已锁定的异常。
最后的一个参数是权限,这个放在后面的鉴权里面说。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyUserDetailsService implements UserDetailsService, SocialUserDetailsService {
private final PasswordEncoder passwordEncoder;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// BCryptPasswordEncoder 每次生成的会不一样,应该在注册的时候保存,这里直接拿数据库保存的
String pwd = passwordEncoder.encode("123123");
System.out.println("PWD:" + pwd);
// 简单实现
return new User(username, pwd,
AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_USER"));
}
}

关于密码的处理,这里使用了 BCryptPasswordEncoder,一般情况下,我们数据库中不可能存储明文密码,SpringSecurity 自然也考虑到了,所以它搞出来了个 PasswordEncoder 接口,这个接口主要定义了两个方法,一个是来处理原始密码的 encode,一个是来比较密码的 matches,你可以自己实现,也可以用它提供的几种,例如 BCryptPasswordEncoder。

这里稍微说下 BCryptPasswordEncoder,它根据 hash + salt 的方式生成密码,特点是即使是相同的密码,每次经过它编码后秘文是不一样的!但是他们 matches 后的结果会是 true,提高了安全性。

自定义认证界面

我们最常用的就是表单认证,至于前端样式,肯定是各不相同,下面就来说如何自定义。
然而最开始的配置里已经剧透了,就是通过 http.loginPage() 来指定登陆的 URL,然后你写你的 html 前端就行了,如果要兼容其他客户端例如 App,你可以写一个 Controller 来判断是不是请求的 html,来确定是返回 html 还是 json;最后只需要记得别忘了把这个地址放到白名单中就行。

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
/**
* SE 在认证时会将原请求缓存进 requestCache
*/
private RequestCache requestCache = new HttpSessionRequestCache();
/**
* 重定向工具类
*/
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

/**
* 当需要身份认证时,执行此方法
* @return 如果是浏览器请求,重定向到认证页面;如果是其他返回 401 状态码提示
*/
@RequestMapping(SecurityConstants.DEFAULT_UN_AUTHENTICATION_URL)
@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
public SimpleResponse reqAuth(HttpServletRequest request, HttpServletResponse response) throws IOException {
SavedRequest savedRequest = requestCache.getRequest(request, response);

if (savedRequest != null) {
String redirectUrl = savedRequest.getRedirectUrl();
if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) {
redirectStrategy.sendRedirect(request, response, securityProperties.getBrowser().getLoginPage());
}
}

return new SimpleResponse("访问的服务需要授权");
}

当用户访问需要授权的接口时,如果检测到未登录,就会重定向到我们配置的登陆页面(浏览器),同时会将 URL 存起来,便于通过认证后再跳转回去,我们通过 RequestCache 这个工具就能拿到 SpringSecurity 存起来的 URL。

然后通过 http.loginProcessingUrl() 来指定表单的提交地址,也就是真正的后台处理登陆请求的地址,后面就是 UsernamePasswordAuthenticationFilter 那一套了。

自定义成功和失败Handler

如何配置前面已经剧透了,想要自定义认证成功或者失败后的逻辑,只需要定义相关的 Handler 即可,例如:

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
/**
* 自定义登陆失败后处理逻辑
* 可以实现 {@link AuthenticationFailureHandler} 进行自定义;
* 默认实现: {@link SimpleUrlAuthenticationFailureHandler}
*
* @author Created by 冰封承諾Andy on 2019/7/22.
*/
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
AuthenticationException e) throws IOException, ServletException {}
}


/**
* 自定义登陆成功后的行为,默认为跳转回原来的地址
*
* 也可以选择实现 {@link AuthenticationSuccessHandler} 的方式来定制;
* 默认规则为 {@link SavedRequestAwareAuthenticationSuccessHandler}
*
* @author Created by 冰封承諾Andy on 2019/7/22.
*/
public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Resource
private ObjectMapper objectMapper;
@Resource
private SecurityProperties securityProperties;

private RequestCache requestCache = new HttpSessionRequestCache();

@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
Authentication authentication) throws IOException, ServletException {
SavedRequest savedRequest = requestCache.getRequest(httpServletRequest, httpServletResponse);
String redirectUrl = null;
if (savedRequest != null) {
redirectUrl = savedRequest.getRedirectUrl();
}

// 当访问非 html 并且设置为 JSON 类型是返回 JSON 格式用户信息
if (!StringUtils.endsWithIgnoreCase(redirectUrl, ".html")
&& LoginResponseType.JSON.equals(securityProperties.getBrowser().getLoginType())) {
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.getWriter().write(objectMapper.writeValueAsString(authentication));
} else {
// 跳转回原来的 URL
super.onAuthenticationSuccess(httpServletRequest, httpServletResponse, authentication);
}
}
}

其中,Authentication 对象封装了认证相关的信息,包括我们自定义的 UserDetails 对象,密码相关的敏感信息如果返回到前端会自动进行过滤。

基于表单的认证流程

表单方式应该是我们用的最多的,所以就来看看它的处理过程,照例上一张图:

表单认证原理

当认证请求进入 UsernamePasswordAuthenticationFilter 这个过滤器后,它会从请求取出用户名和密码信息,封装到一个 UsernamePasswordAuthenticationToken(未认证状态) 中,接下来就到了 AuthenticationManager,它本身不包含认证的逻辑,但它会从一堆 AuthenticationProvider 中选出一个最合适的来进行认证(校验),至于挑选的过程主要是根据 Authentication 的类型进行匹配;
在这些 AuthenticationProvider 中,有一个 supports 方法,它会验证是否支持当前的 AuthenticationToken,如果支持,就进行后面的认证了(会调用我们 UserDetailsService 中的 loadUserByUsername 方法),不支持就跳过,进行下一次循环;
认证过程会通过 UserDetailsService 来获取用户信息(UserDetails),然后进行比较和校验,如果顺利,就会把 UsernamePasswordAuthenticationToken 做一个”已认证”的标记,然后将信息保存到 session,最后的一个步骤会调用我们设置的 Handler,失败的处理流程也是类似。

那么关于请求直接信息共享,还记得在过滤器链最前端有一个 SecurityContextPersistenceFilter,它的作用简单说就是当请求进来的时候检查 session 是否有 Authentication 信息,如果有就将它取出来放到一个 ThreadLocal 里;
当请求完成响应时,它检查 ThreadLocal 是否有认证信息,如果有就放到 session 中去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 获取当前登陆的用户信息
*
* 可以通过 SecurityContextHolder.getContext().getAuthentication() 来进行获取
* 有条件可以直接注入 Authentication 的方式来获取;
* 或者使用 @AuthenticationPrincipal 注解选择性的获取部分信息
*
* SecurityContextHolder 可以简单理解为一个 ThreadLocal,通过最前端的 {@link org.springframework.security.web.context.SecurityContextPersistenceFilter} 过滤器,
* 在每次请求到达时检查 session 是否有登陆信息,有则放到 SecurityContextHolder 中;
* 在请求返回时,检查是否存在 SecurityContextHolder,如果存在则放到 session 中。
*/
@GetMapping("/me")
public Object getCurrentUser(Authentication authentication,
@AuthenticationPrincipal UserDetails user) {
Authentication authentication1 = SecurityContextHolder.getContext().getAuthentication();
// 获取的就是 UserService 中的对象
// User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return user;
}

具体到代码,就是通过 SecurityContextHolder 这个类可以随时随地获取认证信息。


当然,还可以加一些自定义的过滤器,例如来做验证码的校验,示例参考 Github,往过滤链中添加我们自己的过滤器最简单的方案就是直接在文章开始的配置类调用 http 的 addFilterBefore 方法。
现在流行的还有短信验证码,与普通的图形验证码最大的区别就是,使用短信验证码验证通过后是直接认证通过了,而不是单单一个验证码的校验,也可以说它其实是一种登陆方式。

记住我功能

这个也是 Web 应用中常见的一个功能,也有不同的实现方式,在 Spring Security 中的方案是基于 Token 和数据库的,整理成流程图就是这样:

记住我原理

这个 RememberMeAuthenticationFilter 过滤器的位置处在靠后的位置,当前面的认证都无效时再进行“记住我”认证,表单中的 checkbox 控件固定名称是:remember-me
主要的 Java 配置为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Bean
public PersistentTokenRepository persistentTokenRepository() {
// 基于 JDBC 的 “记住我” 实现
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
// 自动执行建表语句
// jdbcTokenRepository.setCreateTableOnStartup(true);

return jdbcTokenRepository;
}


// 主配置
protected void configure(HttpSecurity http) throws Exception {
http.
// 记住我 配置
.rememberMe()
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds())
// 自定义密码处理
.userDetailsService(userDetailsService)
.and()
....
}

既然是基于数据库,那么肯定会需要有一张表,你可以让他自己创建,或者进入到 JdbcTokenRepositoryImpl 的源码中,把里面定义的 SQL 手动执行一下。

自定义认证方式

当了解完默认表单登陆的逻辑后,自定义其他登陆方式也就不那么难了,这里就以短信验证码登陆为例,用的也是蛮多的;首先是要明确的是,短信登陆与表单登陆是完全不同的一种方式,所以不可能在表单认证的 Filter 中搞,肯定是要自己搞一套,那么也就是需要一个拦截短信验证码的 Filter,以及一个进行校验的 Provider,然后校验通过把标识设置为 true 就好了,其他的跟表单方式基本一致。
那么接下来就稍微整理下需要重写的主要类:

  • 封装用户信息的 SmsCodeAuthenticationToken 参考 UsernamePasswordAuthenticationToken,去掉了密码部分,因为短信登陆不需要。
  • 拦截 SMS 请求的过滤器 SmsCodeAuthenticationFilter,它负责从请求取出相关信息封装 SmsCodeAuthenticationToken 等必要的对象。
  • 具体的处理实现 SmsCodeAuthenticationProvider,还记得之前说过 AuthenticationManager 会调用它的 supports 方法来根据 Token 对象的类型匹配是不是用它做认证。
  • 具体做验证的统一 ValidateCodeFilter,处在最前端,仅负责校验验证码。

简单来说执行步骤是:SmsFilter –> AuthenticationToken –> AuthenticationManager –> AuthenticationProvider –> UserDetailsService –> UserDetails –> 获得 Authentication 已认证信息。
既然所需要的类都准备好了,下一步就是将他们配置到 Spring 的环境中,可以定义以下配置类:

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
@Configuration
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;

@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;

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

@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
// 设置 Filter 类似 ValidateCodeFilter,主类
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(httpSecurity.getSharedObject(AuthenticationManager.class));
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);

SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);

// 添加配置,加入到认证之后
httpSecurity.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}

然后,我们可以在文章最开始的那个配置类中,调用 http.apply() 方法让其生效即可。
至此,我们再重新梳理一下流程,首先请求进入 ValidateCodeFilter 进行校验验证码(当然它也可以同时负责校验图形验证码),通过之后就继续过滤链,被 SmsCodeAuthenticationFilter 匹配捕获,然后封装 Token 信息,后面就是 Provider 拿着这些信息去 UserDetailsService 将用户信息获取出来,然后置为“已认证”状态了。

OAuth协议

这里简单回顾一下 OAuth 协议,之前我就写过,连接在这里,虽然写的也是很简单就是了,这里再重新温习一下,OAuth 协议主要包含角色:

  • 服务提供商(Provider)
    • 认证服务器(Authorization Server)
    • 资源服务器(Resource Server)
  • 资源所有者(Resource Owner)
  • 客户端(Client)

接下来说说运作的基本流程:
资源所有者(也就是用户)去访问某个应用的服务(暂且称为客户端吧),然后需要获取其他应用(服务提供商)中的此用户数据,所以客户端会向用户请求授权,用户同意后,服务提供商会发放一个有有效期的令牌给客户端(用户同意操作在服务提供商完成); 之后客户端可以拿着这个令牌去资源服务器获取资源,资源服务器会验证这个令牌的合法性,通过即可返回需要的资源。
具体例子的话,可以想象任何网站的微信、微博登录功能,其中客户端指的就是你访问的那个网站,服务提供商就是微信或者微博,点击后是不是需要跳转到微信扫码或者微博授权页的那个地址呢。

其中,最重要的就是用户同意这个步骤了,至于同意后如何获取令牌,一般我们采用的也就是授权码模式(Authorization Code)和客户端模式(Client Credentials),其中授权码模式最为广泛(更加安全,因为不用将密码暴露给第三方)。

授权码模式的流程(其实跟上面那个基本是一致的,上面就是以授权码模式为例的):
需要授权时,客户端会将用户导向服务提供商的认证服务器,用户需要在认证服务器上完成同意授权,然后认证服务器会返回一个授权码,一般是将这个授权码返回到客户端的后台,然后客户端的后台根据这个授权码再去认证服务器换取令牌; 这样令牌就拿到了,整个过程需要两步,认证服务器也能通过导向的连接携带的参数来确定是那个客户端需要授权; 这样不直接返回令牌而是授权码大大加强来应用间的安全性。

SpringSocial

因为 social 项目调整,目前被标记为 in the Attic,在 Spring2.x 版本中,将相关源码进行了去除,如果使用到了相关到类,只能手动补全.
参见:https://www.jianshu.com/p/e6de152a0b4e

基本原理: social 封装了绝大部分的 OAuth 协议步骤,会在过滤器链加入 Social 自己的过滤器,通过这个过滤器来简化我们的 OAuth 流程,其中根据令牌获取用户信息的实现各不相同只能由用户来提供,其中涉及的 URL 以及必要的参数也要由用户提供;,基本的组成部分可以概括为:

  • ServiceProvider:服务提供商的一个抽象
    必须继承 AbstractOAuth2ApiBinding 类; 它包含 OAuth2Template 这个默认实现和 Api(AbstractOAuth2ApiBinding)
    因为每一个服务提供商定义的接口或者数据对象都可能不同,所以针对每一个服务提供商都应该提供一个抽象与之对应,这个当然就需要用户自己实现。
    示例:QQImplQQServiceProvider
  • ConnectionFactory
    提供的默认实现包含 ServiceProvider 和 ApiAdapter(负责将服务提供商个性化的格式转换为 social 通用的格式)
  • Connection
  • UsersConnectionRepository
    最常用的就是 JDBC 的实现了,需要自己加入到 Spring 容器中,作用就是对数据库中 UsersConnection 表的 CRUD 操作 。

最终的目的就是获得某用户的 Connection,然后联合数据库来获取相关的信息; 想要得到 Connection 就需要 ConnectionFactory,而创建工厂需要 ServiceProvider 和 ApiAdapter; 其中有一些是 social 给我们提供了的,剩下的就需要我们自己实现了。

说到入口,因为 Social 是通过一个 SocialAuthenticationFilter 过滤器来进行操作的,在这个过滤器中默认拦截的是 /auth/{providerId} 请求,用户同意授权后也会再跳转回这个地址,不过是带着授权码 code 回来的,而这个过滤器就是通过是否携带授权码来区分是哪一个步骤的;
当检测到是有 code 的时候,就会去自动的触发换取 token 的机制,如果失败默认的失败路径是 /signin,如果成功解析,它就会拿着获取到的 id 去数据库查相关信息,如果没有查到就跳转到 /signup 默认的注册请求,国内很多网站这里会让你绑定一个手机号(注册逻辑)或者绑定已有账号,但是你也可以让其自动注册然后自动登录(UsersConnectionRepository 会判断 ConnectionSignUp 是否为空,如果不为空会调用这个接口来获取一个 id,从而避免跳转),当然这些配置我们可以配,参考 SocialConfig
并且我们通过配置 ProviderSignInUtils 工具类,可以在自定义的注册或者绑定逻辑里拿到 Social 获取到的第三方用户信息,因为在跳转之前 SocialAuthenticationFilter 会将信息保存到 session 中,这个工具也已经封装了注册(绑定)的方法。
简略流程可以参考:

social流程

本质与 SpringSecurity 没啥区别,都是获取用户信息,校验,最后置为“已认证”状态放到 Context 中,区别的就是获取用户信息与校验的不同。(蓝色的是 Social 提供的,橘色的是我们自己实现的)

绑定与解绑

首先,要获取到当前用户的绑定情况,然后再判断是否进行绑定或者解绑;
它默认提供了一个 地址,用来获取登陆用户的绑定信息,最终会跳转到一个叫 connect/status 的视图,一般情况都是会自定义这个视图的,拼装后返回符合我们期望的数据结构,参考 CustomizeConnectionStatusView

绑定与解绑 Social 也基本都替我们写好了,只需要在登陆状态下 POST 方式访问 /connect/{providerId} 就可以了,对应的它最后也会跳转到一个 /connect/{providerId}Connected 的视图,这个还是要自定义的,参考 CustomizeConnectView
解绑与绑定一致,只是请求方式换成了 DELETE,返回的视图名是 /connect/{providerId}Connect

PS:集成 Social 后,UserDetailsService 记得实现 SocialUserDetailsService 接口,然后返回一个 SocialUserDetails,这个里面才有 id 关于 OAuth 的那些信息。

Session管理

无论你用哪一种方式登陆,最终登陆成功后的用户信息默认是存在服务器的 session 中的,紧接着就会有超时或者说过期的问题,在集群环境下单机 session 更是一个问题。
通常,在 SB 的配置文件中,可以设置超时时间,最低为 1 分钟,在 SecurityConfig 配置类中也可以进行一定的配置,比如失效后的跳转逻辑、在线数量、并发逻辑等。

要解决集群 session 的问题,只要把 session 不放在单独的服务器就行了,例如可以统一放在 Redis,SpringSession 可以简化这套流程的开发,只需要在配置文件中配置 spring.session.store-type 就可以了,基本上是透明的,非侵入,并且配置的 session 管理项也都是有效的。
因为 session 读取会非常频繁,还具有时效性,所以放在 Redis 里是比较合适的。

SpringSecurityOAuth

对于浏览器这种客户端,使用 Cookie-Session 的机制还算是方便,但是对于 App 这一类的客户端,再使用 Cookie-Session 的这种机制做认证就显得十分麻烦,难道每次都要手动保存和添加 cookie 到请求头?当然还有很多一些其他因素,这促使我们使用一种简单方便并且安全的机制来做认证,解决 HTTP 无状态的问题,然后 OAuth 就来了,简单说就是使用令牌来做认证。

SpringSecurityOAuth

OAuth 协议应该有所了解了,SpringSecurityOAuth 相当于简化了我们作为服务提供商的功能开发,服务提供商一般由认证服务器和资源服务器组成,这个前面说过,其中认证服务器最常见的是那四种授权模式,这个 Spring 已经帮我们实现了。

而资源服务器的角色,就是保护我们的资源(接口),他们两个物理上可以是一台机器,资源服务器简单说就是通过一个 Filter 来进行令牌的校验,跟前面所说的 SpringSecurity 原理差不多。

当然,自带的四种授权模式未必能满足我们,例如手机验证码登陆的需求,我们可以进行自定义授权模式,包括存储逻辑与令牌的生成也可以进行个性化。

认证服务器

在 SpringSecurityOAuth 的加持下,实现一个认证服务器非常简单,只需要在 Java 配置类上增加一个 @EnableAuthorizationServer 注解即可。
加入这个注解后,Spring 就会把四种基本的授权模式与 Token 的存储逻辑(默认内存存储)进行自动装配,直接就可以用了,下面以最常用的授权码模式与密码模式来简单说明。

授权码模式

授权码模式的流程不在多说,上面已经讲过了,默认情况下开启注解会发现有这样两个地址映射:

  • /oauth/authorize
    这一个就是默认提供的让用户确认授权的页面,类似你用第三方登录跳到的那个页面,让用户选择哪个账户、什么权限。
    访问这个地址是需要一些参数的,在 OAuth2 协议的官方文档有明确规定。
    PS:要想使用此功能,你系统的用户要有 ROLE_USER 权限,记得在你的 UserDetailsService 中进行配置。
  • /oauth/token
    这个就是用来换取 accessToken 的接口,如果使用授权码模式,就是用上一步得到的 code 来换取 accessToken。
    发送 POST 请求的时候记得要带你申请的 appid 之类的信息,一般通过请求头带 authorization 类型为 Base 的方式编码。

拿到 accessToken 之后就可以去资源服务器获取信息了,只需要在头带上 authorization,这里类型默认用 bearer,值就是 accessToken。
它适合给第三方应用做授权,能避免第三方应用接触到我们应用用户的账号密码的风险。

密码模式

密码模式比授权码模式要更简单,省去了获取 code 的步骤,这种方式用在同一家应用之间是没问题的,就算授权过程需要提供账户密码,但是因为都是自家的应用,还好,就相当于使用账号密码登录了。
过程也没啥说的,参考授权码模式的第二步,仅仅是参数的变化,参数在 OAuth2 规范有明确定义。

其他补充

无论使用哪种方式授权,最终获得的 accessToken 是一样的,Spring 会判断这个用户的 accessToken 是否过期,如果没过期,无论用哪种方式授权,得到的 accessToken 都是一致的。
当然,服务器除了返回 accessToken 过期时间等必要信息,还会有一个刷新的 token,使用这个可以进行刷新 accessToken,使 accessToken 到期时用户可以无感知的“续期”令牌。

资源服务器

资源服务器也是类似,一个 @EnableResourceServer 注解即可搞定,并且可以与认证服务器的注解写在一起,毕竟他们不需要物理上隔离,只是逻辑上的概念。
按照前面所说,它其实是加了一个 Filter,坚持请求头的 authorization 的 bearer 是否合法。
认证过程就区别于传统的 Session 方式了,更加自由一些。

流程解读

大体的流程可以参考下面这张图:

SpringSecurityOAuth原理

其中绿色为类,蓝色为接口(括号里为默认实现)。

  • ClientDetailsService
    用来根据你传递的 client-id 信息读取客户端信息,内容会封装在 ClientDetails 实体。
  • TokenRequest
    它封装了第三方应用请求信息,就是 OAuth 协议中规定的那些请求参数都在这里面。
    同时,Spring 会把 ClientDetails 放进来。
  • TokenGranter
    它背后就是那四种标准的授权模式实现了(外加一种刷新令牌的实现),它会根据 TokenRequest 中的类型来选一个具体实现,然后执行授权。
    不管是哪一种授权,最后都会生成一个 OAuth2Request (TokenRequest 和 ClientDetails 的整合体)和 Authentication(包含授权用户的一些信息,那个用户在做授权,由 UserDetailsService 获得),他们两个最后会合并到 OAuth2Authentication 中去。
  • AuthorizationServerTokenServices
    这就是具体生成令牌的接口了,它根据 OAuth2Authentication 的信息就可以生成、存储令牌,并且允许通过 Enhancer 对令牌进行改造。

在授权码的方式中,权限是由用户最终决定的,你想获取用户的全部信息,用户未必会全部给你,当然需要支持用户勾选的情况下,所以在 TokenEndpoint 过程中,如果是授权码模式的第一阶段会把 Scope 置空,由第二步的用户来进行填充。
创建令牌的过程中,会先检查当前用户是否已经创建过令牌、令牌是否过期、是否有刷新令牌,都没有后才会执行创建逻辑,创建的最后一步会判断你是否定义了增强类,如果有就按照你的逻辑来对令牌进行自定义。

自定义登陆

自带的标准的四种方式并不一定会满足我们的业务需求,就像之前的短信验证码登陆,这里同样也需要自己实现。
根据流程图可以知道,只需要调用 AuthorizationServerTokenServices 就可以产生令牌了,而构建它需要 OAuth2Authentication 对象,也就是 OAuth2Request 和 Authentication。
然后在 SpringSecurity 中我们知道有个 handle 处理逻辑,只需要在成功的 handle 中调用一下这个 service 就可以了,并且 Authentication 对象是直接有现成的,那就只剩下 OAuth2Request 对象,而它可以从请求中提取数据拼装。
示例可参考 MyAuthenticationSuccessHandler,经过它改造后,使用普通的表单登陆地址,只需要填入用户名和密码就可以拿到 assessToken 了。

对于 App 短信登陆,要解决的就是去 session 后验证码的校验,App 肯定不会携带 cookie 这一类用来标识,也就是服务器端不可能存在传统的 session 中进行验证;可以将验证码存在 Redis,以一个机器 id 作为 key 或者手机号作为 key,用户登陆的时候需要在请求头或者请求参数携带这个标识,用于校验,这也就要重写 SMS 验证码的校验逻辑。

对于 App 中的第三方登录,一般都是有专门的 SDK,最终用户同意后会拿到一个 openid ,而我们的系统需要提供一个接口,使用 openid 来换取 AssessToken,这样就算是登录了。
当然也不一定第三方应用会给 openid,也可能会给一个授权码,需要服务器后台拿着授权码去换 token,这样的情况也需要单独处理,即在 App 的后端,要通过后处理器来处理授权码模式的请求,跟成功的 handle 一样,最后返回个 assessToken 就好了。

使用JWT

首先简单说一下 JWT 的特点:

  • 自包含
  • 密签(签名,并不是加密)
  • 可扩展

因为这些特点,它肯定也是比默认的 UUID 长不少,携带的信息越多自然就越长,我的话还是习惯用简洁一点的 UUID,虽然它并不能代表什么信息。
要想使用 JWT,比较好的一种方案就是通过 TokenStore 将它默认的 UUID 进行转换和存储,示例参考 TokenStoreConfig 这个类。

SpringSecurity 默认是支持 SSO 的,不过这部分我没去细细研究,客户端只需要使用 @EnableOAuth2Sso 注解和一点配置就可以了,自动授权的相关类见 WhitelabelApprovalEndpoint,具体的示例参考见:https://github.com/jojozhai/security

SpringSecurity授权

简单说,就是控制你能干什么,不能干什么;在 SpringSecurity 中最简单的一种配置就是之前继承的 AbstractChannelSecurityConfig 类中的那个 http 对象,继续往下写:

1
2
3
4
5
6
7
8
@Override
protected void configure(HttpSecurity http) throws Exception {
// ...

http.authorizeRequests()
.antMatchers(HttpMethod.POST, "/user/*")
.hasRole("Admin");
}

这样就需要你在 UserDetailsService 中赋予 “ROLEAdmin” 权限后才能访问此 URL,记得加 `ROLE` 前缀。

运行原理

这里还是简单看一下它的执行过程,相信这张图还是很有印象的:

SE授权

这次的焦点是在 AnonymousAuthenticationFilter,处于那一堆身份认证的最后一个,它的作用也很简单,当检测到到达这个过滤器时 Authentication 还是空的话,也就是前面那些都没有匹配到,就会创建一个 AnonymousAuthenticationToken,也就是代表匿名用户了(放进去的用户信息为 anonymousUser - ROLE_ANONYMOUS)。
最终,由 FilterSecurityInterceptor (是个过滤器)来判断用户是否有足够的权限请求的资源,具体的执行逻辑会委托给 AccessDecisionManager,它有一个抽象实现 AbstractAccessDecisionManager 和几个具体实现:

  • AffirmativeBased(默认),只要一个否定就否定
  • ConsensusBased,少数服从多数
  • UnanimousBased,只要有一个成功,即算作成功

简单说,它就是来统计投票的,具体投票是一堆 AccessDecisionVoter 对象(现在主要有 WebExpressionVoter 承担了,因为 3 之后的版本有了 Spring 表达式),根据选票与选择的 AccessDecisionManager,最终决定是否放行。
如果投票不通过就会抛出一个异常,被前面的过滤器捕获,然后进行相应的处理。

权限表达式

最终由 AccessDecisionVoter 评估的就是权限表达式,到达 AccessDecisionVoter 之前 Spring 会将权限信息转换成权限表达式的类型,之前在代码中写的 permitAll() 就是一个权限表达式,相信列表请参考这里,当然也可以使用多个,但是就需要手动写表达式了,例如:

1
.access("hasRole('admin') and hasIpAddress('192.168.1.0/24')");

当然也能使用自己写的方法来进行验证:

1
2
3
4
5
6
7
8
9
10
@Override
public boolean config(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry config) {
config
.antMatchers(HttpMethod.GET, "/fonts/**").permitAll()
.antMatchers(HttpMethod.GET,
"/**/*.html",
"/resource").authenticated()
.anyRequest()
.access("@rbacService.hasPermission(request, authentication)");
return true;

其中 rbacService 指的是 Bean 的名字,hasPermission 是验证方法,返回一个布尔,后面跟上参数就 OK 了。

持久化存储

这些权限的对应简单的话还好,如果非常复杂使用硬编码的方式就很蛋疼了,那肯定支持从数据库读取,建表就采用一般的 RBAC 基于角色的控制就行了,具体就是用户表、角色表、资源表、用户角色关系表、角色资源关系表。
然后自定义你的 UserDetailsService 和 UserDetails 从数据库读取,加入该用户对应的权限信息,然后通过上面的自定义验证方法来进行校验。

拓展

JWT(JSON Web Token)

喜欢就请我吃包辣条吧!

评论框加载失败,无法访问 Disqus

你可能需要魔法上网~~