Spring Security5整合JWT认证和授权
JWT介绍
JWT原理
JWT是JSON Web Token的缩写,是目前最流行的跨域认证解决方法。
互联网服务认证的一般流程是:
- 用户向服务器发送账号、密码
- 服务器验证通过后,将用户的角色、登录时间等信息保存到当前会话中
- 同时,服务器向用户返回一个session_id(一般保存在cookie里)
- 用户再次发送请求时,把含有session_id的cookie发送给服务器
- 服务器收到session_id,查找session,提取用户信息
上面的认证模式,存在以下缺点:
- cookie不允许跨域
- 因为每台服务器都必须保存session对象,所以扩展性不好
JWT认证原理是:
- 用户向服务器发送账号、密码
- 服务器验证通过后,生成token令牌返回给客户端(token可以包含用户信息)
- 用户再次请求时,把token放到请求头
Authorization
里 - 服务器收到请求,验证token合法后放行请求
JWT token令牌可以包含用户身份、登录时间等信息,这样登录状态保持者由服务器端变为客户端,服务器变成无状态了;token放到请求头,实现了跨域
JWT数据结构
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
JWT由三部分组成:
- Header(头部)
- Payload(负载)
- Signature(签名)
表现形式为:Header.Payload.Signature
Header
Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子:
{
"alg": "HS256",
"typ": "JWT"
}
上面代码中,alg
属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ
属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT
。
上面的 JSON 对象使用 Base64URL 算法转成字符串
Payload
Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段:
-
iss (issuer):签发人
-
exp (expiration time):过期时间
-
sub (subject):主题
-
aud (audience):受众
-
nbf (Not Before):生效时间
-
iat (Issued At):签发时间
-
jti (JWT ID):编号
当然,用户也可以定义私有字段。
这个 JSON 对象也要使用 Base64URL 算法转成字符串
Signature
Signature 部分是对前两部分的签名,防止数据篡改
签名算法如下:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"."分隔
JWT认证和授权
Security是基于AOP和Servlet过滤器的安全框架,为了实现JWT要重写那些方法、自定义那些过滤器需要首先了解security自带的过滤器。security默认过滤器链如下:
- org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
- org.springframework.security.web.context.SecurityContextPersistenceFilter
- org.springframework.security.web.header.HeaderWriterFilter
- org.springframework.security.web.authentication.logout.LogoutFilter
- org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
- org.springframework.security.web.savedrequest.RequestCacheAwareFilter
- org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
- org.springframework.security.web.authentication.AnonymousAuthenticationFilter
- org.springframework.security.web.session.SessionManagementFilter
- org.springframework.security.web.access.ExceptionTranslationFilter
- org.springframework.security.web.access.intercept.FilterSecurityInterceptor
SecurityContextPersistenceFilter
这个过滤器有两个作用:
- 用户发送请求时,从session对象提取用户信息,保存到SecurityContextHolder的securitycontext中
- 当前请求响应结束时,把SecurityContextHolder的securitycontext保存的用户信息放到session,便于下次请求时共享数据;同时将SecurityContextHolder的securitycontext清空
由于禁用session功能,所以该过滤器只剩一个作用即把SecurityContextHolder的securitycontext清空。举例来说明为何要清空securitycontext:用户1发送一个请求,由线程M处理,当响应完成线程M放回线程池;用户2发送一个请求,本次请求同样由线程M处理,由于securitycontext没有清空,理应储存用户2的信息但此时储存的是用户1的信息,造成用户信息不符
UsernamePasswordAuthenticationFilter
UsernamePasswordAuthenticationFilter
继承自AbstractAuthenticationProcessingFilter
,处理逻辑在doFilter
方法中:
- 当请求被
UsernamePasswordAuthenticationFilter
拦截时,判断请求路径是否匹配登录URL,若不匹配继续执行下个过滤器;否则,执行步骤2 - 调用
attemptAuthentication
方法进行认证。UsernamePasswordAuthenticationFilter
重写了attemptAuthentication
方法,负责读取表单登录参数,委托AuthenticationManager
进行认证,返回一个认证过的token(null表示认证失败) - 判断token是否为null,非null表示认证成功,null表示认证失败
- 若认证成功,调用
successfulAuthentication
。该方法把认证过的token放入securitycontext供后续请求授权,同时该方法预留一个扩展点(AuthenticationSuccessHandler.onAuthenticationSuccess方法
),进行认证成功后的处理 - 若认证失败,同样可以扩展
uthenticationFailureHandler.onAuthenticationFailure
进行认证失败后的处理 - 只要当前请求路径匹配登录URL,那么无论认证成功还是失败,当前请求都会响应完成,不再执行过滤器链
UsernamePasswordAuthenticationFilter
的attemptAuthentication
方法,执行逻辑如下:
- 从请求中获取表单参数。因为使用
HttpServletRequest.getParameter
方法获取参数,它只能处理Content-Type为application/x-www-form-urlencoded或multipart/form-data的请求,若是application/json则无法获取值 - 把步骤1获取的账号、密码封装成
UsernamePasswordAuthenticationToken
对象,创建未认证的token。UsernamePasswordAuthenticationToken
有两个重载的构造方法,其中public UsernamePasswordAuthenticationToken(Object principal, Object credentials)
创建未经认证的token,public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities)
创建已认证的token - 获取认证管理器
AuthenticationManager
,其缺省实现为ProviderManager
,调用其authenticate
进行认证 ProviderManager
的authenticate
是个模板方法,它遍历所有AuthenticationProvider
,直至找到支持认证某类型token的AuthenticationProvider
,调用AuthenticationProvider.authenticate
方法认证,AuthenticationProvider.authenticate
加载正确的账号、密码进行比较验证AuthenticationManager.authenticate
方法返回一个已认证的token
AnonymousAuthenticationFilter
AnonymousAuthenticationFilter
负责创建匿名token:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
if (SecurityContextHolder.getContext().getAuthentication() == null) {
SecurityContextHolder.getContext().setAuthentication(this.createAuthentication((HttpServletRequest)req));
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.of(() -> {
return "Set SecurityContextHolder to " + SecurityContextHolder.getContext().getAuthentication();
}));
} else {
this.logger.debug("Set SecurityContextHolder to anonymous SecurityContext");
}
} else if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.of(() -> {
return "Did not set SecurityContextHolder since already authenticated " + SecurityContextHolder.getContext().getAuthentication();
}));
}
chain.doFilter(req, res);
}
如果当前用户没有认证,会创建一个匿名token,用户是否能读取资源交由FilterSecurityInterceptor
过滤器委托给决策管理器判断是否有权限读取
实现思路
JWT认证思路:
- 利用Security原生的表单认证过滤器验证用户名、密码
- 验证通过后自定义
AuthenticationSuccessHandler
认证成功处理器,由该处理器生成token令牌
JWT授权思路:
- 使用JWT目的是让服务器变成无状态,不用session共享数据,所以要禁用security的session功能(http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- token令牌数据结构设计时,payload部分要储存用户名、角色信息
- token令牌有两个作用:
- 认证, 用户发送的token合法即代表认证成功
- 授权,令牌验证成功后提取角色信息,构造认证过的token,将其放到securitycontext,具体权限判断交给security框架处理
- 自己实现一个过滤器,拦截用户请求,实现(3)中所说的功能
代码实现
创建JWT工具类
JWT的Java实现,利用开源的java-jwt
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.12.0</version>
</dependency>
我们对java-jwt提供的API进行封装,便于创建、验证、提取claim
@Slf4j
public class JWTUtil {
// 携带token的请求头名字
public final static String TOKEN_HEADER = "Authorization";
//token的前缀
public final static String TOKEN_PREFIX = "Bearer ";
// 默认密钥
public final static String DEFAULT_SECRET = "mySecret";
// 用户身份
private final static String ROLES_CLAIM = "roles";
// token有效期,单位分钟;
private final static long EXPIRE_TIME = 5 * 60 * 1000;
// 设置Remember-me功能后的token有效期
private final static long EXPIRE_TIME_REMEMBER = 7 * 24 * 60 * 60 * 1000;
// 创建token
public static String createToken(String username, List role, String secret, boolean rememberMe) {
Date expireDate = rememberMe ? new Date(System.currentTimeMillis() + EXPIRE_TIME_REMEMBER) : new Date(System.currentTimeMillis() + EXPIRE_TIME);
try {
// 创建签名的算法实例
Algorithm algorithm = Algorithm.HMAC256(secret);
String token = JWT.create()
.withExpiresAt(expireDate)
.withClaim("username", username)
.withClaim(ROLES_CLAIM, role)
.sign(algorithm);
return token;
} catch (JWTCreationException jwtCreationException) {
log.warn("Token create failed");
return null;
}
}
// 验证token
public static boolean verifyToken(String token, String secret) {
try{
Algorithm algorithm = Algorithm.HMAC256(secret);
// 构建JWT验证器,token合法同时pyload必须含有私有字段username且值一致
// token过期也会验证失败
JWTVerifier verifier = JWT.require(algorithm)
.build();
// 验证token
DecodedJWT decodedJWT = verifier.verify(token);
return true;
} catch (JWTVerificationException jwtVerificationException) {
log.warn("token验证失败");
return false;
}
}
// 获取username
public static String getUsername(String token) {
try {
// 因此获取载荷信息不需要密钥
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException jwtDecodeException) {
log.warn("提取用户姓名时,token解码失败");
return null;
}
}
public static List<String> getRole(String token) {
try {
// 因此获取载荷信息不需要密钥
DecodedJWT jwt = JWT.decode(token);
// asList方法需要指定容器元素的类型
return jwt.getClaim(ROLES_CLAIM).asList(String.class);
} catch (JWTDecodeException jwtDecodeException) {
log.warn("提取身份时,token解码失败");
return null;
}
}
}
认证
验证账号、密码交给UsernamePasswordAuthenticationFilter
,不用修改代码
认证成功后,需要生成token返回给客户端,我们通过扩展AuthenticationSuccessHandler.onAuthenticationSuccess方法
实现
@Component
public class JWTAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
ResponseData responseData = new ResponseData();
responseData.setCode("200");
responseData.setMessage("登录成功!");
// 提取用户名,准备写入token
String username = authentication.getName();
// 提取角色,转为List<String>对象,写入token
List<String> roles = new ArrayList<>();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
for (GrantedAuthority authority : authorities){
roles.add(authority.getAuthority());
}
// 创建token
String token = JWTUtil.createToken(username, roles, JWTUtil.DEFAULT_SECRET, true);
httpServletResponse.setCharacterEncoding("utf-8");
// 为了跨域,把token放到响应头WWW-Authenticate里
httpServletResponse.setHeader("WWW-Authenticate", JWTUtil.TOKEN_PREFIX + token);
// 写入响应里
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(httpServletResponse.getWriter(), responseData);
}
}
为了统一返回值,我们封装了一个ResponseData
对象
授权
自定义一个过滤器JWTAuthorizationFilter
,验证token,token验证成功后认为认证成功
@Slf4j
public class JWTAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = getTokenFromRequestHeader(request);
Authentication verifyResult = verefyToken(token, JWTUtil.DEFAULT_SECRET);
if (verifyResult == null) {
// 即便验证失败,也继续调用过滤链,匿名过滤器生成匿名令牌
chain.doFilter(request, response);
return;
} else {
log.info("token令牌验证成功");
SecurityContextHolder.getContext().setAuthentication(verifyResult);
chain.doFilter(request, response);
}
}
// 从请求头获取token
private String getTokenFromRequestHeader(HttpServletRequest request) {
String header = request.getHeader(JWTUtil.TOKEN_HEADER);
if (header == null || !header.startsWith(JWTUtil.TOKEN_PREFIX)) {
log.info("请求头不含JWT token, 调用下个过滤器");
return null;
}
String token = header.split(" ")[1].trim();
return token;
}
// 验证token,并生成认证后的token
private UsernamePasswordAuthenticationToken verefyToken(String token, String secret) {
if (token == null) {
return null;
}
// 认证失败,返回null
if (!JWTUtil.verifyToken(token, secret)) {
return null;
}
// 提取用户名
String username = JWTUtil.getUsername(token);
// 定义权限列表
List<GrantedAuthority> authorities = new ArrayList<>();
// 从token提取角色
List<String> roles = JWTUtil.getRole(token);
for (String role : roles) {
log.info("用户身份是:" + role);
authorities.add(new SimpleGrantedAuthority(role));
}
// 构建认证过的token
return new UsernamePasswordAuthenticationToken(username, null, authorities);
}
}
OncePerRequestFilter
保证当前请求中,此过滤器只被调用一次,执行逻辑在doFilterInternal
security配置
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AjaxAuthenticationEntryPoint ajaxAuthenticationEntryPoint;
@Autowired
private JWTAuthenticationSuccessHandler jwtAuthenticationSuccessHandler;
@Autowired
private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.successHandler(jwtAuthenticationSuccessHandler)
.failureHandler(ajaxAuthenticationFailureHandler)
.permitAll()
.and()
.addFilterAfter(new JWTAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling().authenticationEntryPoint(ajaxAuthenticationEntryPoint);
}
}
配置里取消了session功能,把我们定义的过滤器添加到过滤链中;同时,定义ajaxAuthenticationEntryPoint
处理未认证用户访问未授权资源时抛出的异常
@Component
public class AjaxAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
ResponseData responseData = new ResponseData();
responseData.setCode("401");
responseData.setMessage("匿名用户,请先登录再访问!");
httpServletResponse.setCharacterEncoding("utf-8");
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(httpServletResponse.getWriter(), responseData);
}
}
参考
Spring Security3源码分析(5)-SecurityContextPersistenceFilter分析
Spring Security addFilter() 顺序问题
前后端联调之Form Data与Request Payload,你真的了解吗?