单点登录与多点登录
- 单点登录:同一个账号在同一时间只有一个token有效。一旦生成新的token,所有旧token失效。
- 多点登录:同一个账号在同一时间可多次登录,每次登陆都会获得一个token。这些token的有效期是隔离的,不受新生成token的影响。
对于token,若要实现单点登录,则必须将所有token保存在服务端。这实际上与token服务端不负责保存的本质相悖。若要实现单点登录,建议使用session。
下面将实现多点登录。
原理与思路
Spring Security的认证与授权是分开的:
- 认证:负责身份验证并颁发凭证。
- 授权:根据凭证判断是否可访问指定资源。
Spring Security的核心就是各种Filter,因此认证和授权也是在Filter中完成。
故而,若要在Spring Security中使用token,则需要分别对认证和授权进行定制。
整体流程为:通过认证,生成token并返回给前端→前端请求的header中附带token→资源服务器验证token的有效性并判定其权限,返回请求结果。
其中在资源服务器验证token的有效性这一步,可根据实际情况来进行定制:
- 只验证token有效期和密钥。该方式不需要向数据库/缓存查询用户名和密码。
- 验证token中的用户是否存在。该方式需要向数据库/缓存查询用户名。
对于后者,每次验证token都要查询数据库/缓存,虽然安全性更高,但效率会降低。
推荐将用户相关的信息直接保存到token中,单纯判断token有效期即可。
认证
对用户提供的用户名和密码进行验证后,需要将token返回给用户。认证有两种思路:
- 重载默认的认证Filter。
- 直接自行认证。
重载默认的认证Filter
认证由UsernamePasswordAuthenticationFilter
负责。因此若希望通过Spring Security的流程来认证,则需要重载UsernamePasswordAuthenticationFilter
,在其中设置登录的接口,验证用户信息,生成token并返回。
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
private TokenManager tokenManager;
public TokenLoginFilter(AuthenticationManager authenticationManager, TokenManager tokenManager) {
this.authenticationManager = authenticationManager;
this.tokenManager = tokenManager;
this.setPostOnly(false);
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/login")); // 定义登录请求接口
}
@Override
public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws AuthenticationException {
try {
User user = 根据请求查询用户信息;
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword(), new ArrayList<>()));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* 认证成功
*/
@Override
protected void successfulAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain chain, Authentication authentication) throws IOException, ServletException {
String token = 生成token;
httpServletResponse.getWriter().write(token);
}
/**
* 认证失败
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest httpServletRequestuest, HttpServletResponse httpServletResponseponse, AuthenticationException e) throws IOException, ServletException {
httpServletResponse.getWriter().write("认证失败");
}
}
该方式Spring Security会进行密码验证,因此在WebSecurityConfigurerAdapter
中需要对密码验证方式进行设置:
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 设置密码验证方式
*/
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
同时需要自定义UserDetailsService
和UserDetails
提供给Spring Security。
直接自行认证
考虑到用户传入用户名和密码,那么可以直接在WebSecurityConfigurerAdapter.configure()
中将登录接口的访问开放。这样登录接口就成为一个白名单接口,可任意访问。
在某个controller中实现登录接口,在其中对传入的用户名和密码进行验证,然后生成token并返回。
由于密码验证不再由Spring Security负责,因此不需要在WebSecurityConfigurerAdapter
中对密码验证方式进行设置,也不再需要自定义UserDetailsService
和UserDetails
。
授权
当Spring Security收到某个非白名单请求时,会对其进行逐级Filter过滤。在这些Filter中,会有某一级Filter取出请求中的凭证进行验证,若验证通过则根据凭证中的信息构造UsernamePasswordAuthenticationToken
对象,并将该对象调用SecurityContextHolder.getContext().setAuthentication(authentication);
来设置给Spring Security上下文。
于是,SecurityContextHolder.getContext().getAuthentication()
不再为null
,从而Spring Security判断验证通过。
基于此,前端获取到token后,需要在请求时将token附加在header中作为凭证。通常使用Authorization
字段名,并在token前附加Bearer
前缀。
对应地服务端需要自定义一个Filter,在该Filter中取出请求header中的token进行验证,然后生成UsernamePasswordAuthenticationToken
对象并设置给Spring Security上下文。最后在WebSecurityConfigurerAdapter.configure()
中将该Filter设置给HttpSecurity
即可。
因此,该自定义的Filter可以是派生自BasicAuthenticationFilter
,也可以是派生自OncePerRequestFilter
,只要可顺利挂载到HttpSecurity
即可。
两种方式如下:
基于BasicAuthenticationFilter
:
/**
* 自定义token验证Filter,基于 BasicAuthenticationFilter
*/
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequestuest, HttpServletResponse httpServletResponseponse, FilterChain chain)
throws IOException, ServletException {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken();
判断token有效性并填充authentication
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 将自定义的Filter设置给HttpSecurity
http.addFilter(new TokenAuthenticationFilter(authenticationManager()));
}
}
基于OncePerRequestFilter
:
/**
* 自定义token验证Filter,基于 OncePerRequestFilter
*/
public class TokenAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequestuest, HttpServletResponse httpServletResponseponse, FilterChain chain)
throws IOException, ServletException {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken();
判断token有效性并填充authentication
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 将自定义的Filter设置给HttpSecurity
http.addFilterBefore(new TokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
两种方式:
- 定义验证Filter时仅父类不同,逻辑相同。
- 设置给
HttpSecurity
的方式不同。
架构
认证与资源访问授权是分开的,故而认证与资源服务器可以分开。
若认证服务器仅有颁发token的作用,则:
- 认证服务器,若重载默认的认证Filter,则认证服务器需要集成Spring Security。
- 认证服务器,若直接自行认证,则认证服务器不需要集成Spring Security。
若认证服务器除了颁发token还提供一些用户信息相关的接口,则建议集成Spring Security。
多角色
认证
例如,系统中需要存在2个角色:PC端用户与移动端用户,则有多种认证方案:
- 直接使用两套认证系统,PC认证系统只验证PC端账号密码,移动端访问PC认证时无法认证通过。
- 使用一套认证系统,但提供2个登录接口。PC端只能访问PC端登录接口,移动端访问PC端登录接口无法认证通过。
无论是哪种认证方案,都需要将登录用户的类型存入token中。这样资源服务器在收到请求时才能根据用户类型来判定角色。当然,也可以直接将角色存入token中。
资源访问
资源服务器在验证token后会生成UsernamePasswordAuthenticationToken
对象。该对象需要设置角色,不同的角色有不同的资源访问权限。
根据token中存储的用户类型,可以设置对应的角色。若token中已存入现成的角色,也可直接使用。
token过期
服务端在TokenAuthenticationFilter.doFilterInternal()
中需要判断token有效性。对于无效的token,就需要进行拦截并返回token无效信息。
但考虑到有些接口是不需要权限就可以访问的,例如/login
,因此请求若不包含token信息是不可以判定为token无效的。
故而,整体逻辑应为:
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws IOException, ServletException {
if (token为null或空) {
继续执行filterChain
return ;
}
if (token有效) {
生成UsernamePasswordAuthenticationToken并设置给SecurityContext
继续执行filterChain
} else {
修改httpServletResponse,返回token无效信息
}
}
该逻辑的关键点在于:若请求附加了token,则一定优先判定token的有效性,无论访问的接口是什么。
也就是说,即使访问的是/login
这样不需要权限的接口,只要请求header中带有token,则依然会优先判定token有效性。
故而需要前端对token进行维护。若token失效,前端需要将token从请求header中去掉。
token自动刷新
思路
前端主动请求
前端主动请求通常有2种方式:
- 前端将用户的登录信息进行保存,当前端收到token失效的信息后,取出保存的登录信息再次调用登录接口来获取新的token。
- 第一次登陆时服务端会返回2个token,一个用于请求,一个用于刷新。当请求token失效后,前端用保存的刷新token再次请求服务端接口来获取新的token。
前端主动请求token的方式实际会影响到用户体验。
例如,当token过期时,用户点击了某个查询按钮,但此时服务端会判定为token失效,并将失效信息返回给前端。前端收到失效信息后用保存的信息再次请求新的token。这样确实实现了前端token的刷新,但用户点击按钮提交的查询操作被丢弃了。
虽然确实可以通过服务端返回时附带原始请求信息这样的方式来解决,但这样流程较为复杂,且请求次数较多。
不建议使用前端主动请求的方式。
服务端刷新
服务端刷新的思路为:每次服务端收到请求时都对token进行判定。若token符合刷新条件,则生成一个新的token并放入返回的header中,然后继续执行请求逻辑。对应地前端需要对请求的返回进行拦截,判断返回header中是否包含新的token。若是,则更新保存的token。
该方式不影响原始请求,故而不会影响到用户体验。推荐该方式。
token刷新策略
token可以在每次请求时刷新,也可以在临界期刷新,还可以在过期后刷新。
在这里只考虑临界期刷新,即:当服务端判断token已处于临界期时就生成一个新的token。
例如,token有效期为60分钟,临界时间为15分钟,那么[45, 60]分钟这个区间就是临界期。当在该时间请求服务端时,服务端就会生成新的token并附加到请求的header中。
该方式意味着,只要有请求,那么token将一直刷新,前端会始终保持token有效状态。
若要在此基础上增加限制,通常有2种方式:
- 限制刷新有效期。登录时将一个刷新有效期附加在token中。当token要进行临界期刷新时,判断当前时间是否处于刷新有效期中。若否,则不再生成token。
- 限制刷新次数。登录时将一个
int
值附加在token中,作为刷新次数。每次token进行临界期刷新时都令该值-1
,并附加到新token中。当该值为0时,不再生成token。
实现
基于以上思路,实现为:
public class TokenAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain)
throws IOException, ServletException {
String token = 从HttpServletRequest获取token;
// token为null或空,则交给其他过滤器。此时只有开放接口可被访问。
if (token == null || token.isEmpty()) {
filterChain.doFilter(httpServletRequest, httpServletResponse);
return ;
}
/*
*若token不为空,则一定先判断token是否有效。若无效,则直接返回token无效,即使当前接口为开放接口。
*/
// token不为空,则检查token是否有效。
if (token是否有效) {
// token有效,则判断token是否需要刷新。
if (token是否处于临界期) {
// token需要刷新
String newToken = 生成新token;
// 将其放入header中
httpServletResponse.addHeader("authorization", newToken);
// 必须开放cors,前端才可访问自定义header。
httpServletResponse.setHeader("Access-Control-Expose-Headers", "*");
}
UsernamePasswordAuthenticationToken authentication = getAuthentication(token);
if (authentication != null) {
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
} else {
// token无效,则告知前端。不再继续调用Filter链。
httpServletResponse.getWriter().write("token无效,请重新登录");
}
}
/**
* 生成UsernamePasswordAuthenticationToken
* @param token
* @return
*/
private UsernamePasswordAuthenticationToken getAuthentication(String token) {
if (SecurityContextHolder.getContext().getAuthentication() == null) {
// 解析token信息
Map tokenClaimMap = 获取token中保存的信息;
if (tokenClaimMap!=null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 权限,多个权限以`,`号隔开
String[] authorityArray = ((String)tokenClaimMap.getOrDefault("authorities", "")).split(",");
return new UsernamePasswordAuthenticationToken(tokenClaimMap.get("account"), token, AuthorityUtils.createAuthorityList(authorityArray));
}
}
return null;
}
}
代码中中文描述的内容需要自行实现。
这样,就实现了临界期刷新,只要有请求,那么token将一直刷新,前端会始终保持token有效状态。
若要增加 有效期 / 次数 判断,则在if (token是否处于临界期)
逻辑内增加对应的 有效期 / 次数 判断即可。
其中需要注意的是必须httpServletResponse.setHeader("Access-Control-Expose-Headers", "*");
,前端收到的HttpServletResponse
才能看到自定义的header字段authorization
。
优势
整体思路使用直接自行认证+授权,故而可以定义多个登录接口,生成不同类型的token,同时可在token中附加各种类型相关的信息。
授权时,取出token中的特定信息即可得知该token的类型,从而可执行不同的处理。
例如:
- 可实现多种类型账号登录。
- token的生成与刷新策略可以分开。例如生成时有效期为60分钟,每次刷新有效期为40分钟。
- 不同类型账号的token可使用不同的token生成策略,例如PC端登录时生成的token有效期为60分钟,移动端登录时生成的token有效期为120分钟。
- 不同类型账号的token可使用不同的token刷新策略,例如PC账号每次登陆可刷新token3次,移动设备登录则是在三小时内可刷新。
- 可实现对其他应用的授权。