SpringSecurity整合JWT
JWT
常见的认证机制
HTTP Basic Auth
HTTP Basic Auth简单点说就是每次请求API时都提供用户的username和password。简言之,Basic Auth是配合RESTful API 使用的最简单的认证方式,只需提供用户名密码即可,但由于有把用户名密码暴露给第三方客户端的风险,在生产环境下被使用的越来越少。因此,在开发对外开放的RESTful API时,尽量避免采用HTTP Basic Auth。
Cookie Auth
? Cookie认证机制就是为一次请求认证在服务端创建一个Session对象,同时在客户端的浏览器端创建了一个Cookie对象;通过客户端带上来Cookie对象来与服务器端的session对象匹配来实现状态管理的。默认的,当我们关闭浏览器的时候,cookie会被删除。但可以通过修改cookie 的expire time使cookie在一定时间内有效。
OAuth
OAuth(开放授权,Open Authorization)是一个开放的授权标准,允许用户让第三方应用访问该用户在某一web服务上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。如网站通过微信、微博登录等,主要用于第三方登录。
OAuth允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的第三方系统(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。
下面是OAuth2.0的流程:
这种基于OAuth的认证机制适用于个人消费者类的互联网产品,如社交类APP等应用,但是不太适合拥有自有认证权限管理的企业应用。
缺点:过重。
Token Auth
使用基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录。大概的流程是这样的:
-
客户端使用用户名跟密码请求登录
-
服务端收到请求,去验证用户名与密码
-
验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端
-
客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里
-
客户端每次向服务端请求资源的时候需要带着服务端签发的 Token
-
服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据
比第一种方式更安全,比第二种方式更节约服务器资源,比第三种方式更加轻量。
具体,Token Auth的优点(Token机制相对于Cookie机制又有什么好处呢?):
-
支持跨域访问: Cookie是不允许垮域访问的,这一点对Token机制是不存在的,前提是传输的用户认证信息通过HTTP头传输.
-
无状态(也称:服务端可扩展行):Token机制在服务端不需要存储session信息,因为Token 自身包含了所有登录用户的信息,只需要在客户端的cookie或本地介质存储状态信息.
-
更适用CDN: 可以通过内容分发网络请求你服务端的所有资料(如:javascript,HTML,图片等),而你的服务端只要提供API即可.
-
去耦: 不需要绑定到一个特定的身份验证方案。Token可以在任何地方生成,只要在你的API被调用的时候,你可以进行Token生成调用即可.
-
更适用于移动应用: 当你的客户端是一个原生平台(iOS, Android,Windows 10等)时,Cookie是不被支持的(你需要通过Cookie容器进行处理),这时采用Token认证机制就会简单得多。
-
CSRF:因为不再依赖于Cookie,所以你就不需要考虑对CSRF(跨站请求伪造)的防范。
-
性能: 一次网络往返时间(通过数据库查询session信息)总比做一次HMACSHA256计算的Token验证和解析要费时得多.
-
不需要为登录页面做特殊处理: 如果你使用Protractor 做功能测试的时候,不再需要为登录页面做特殊处理.
-
基于标准化:你的API可以采用标准化的 JSON Web Token (JWT). 这个标准已经存在多个后端库(.NET, Ruby, Java,Python, PHP)和多家公司的支持(如:Firebase,Google, Microsoft).
JWT简介
什么是JWT
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。
官网: https://jwt.io/
标准: https://tools.ietf.org/html/rfc7519
JWT令牌的优点:
-
jwt基于json,非常方便解析。
-
可以在令牌中自定义丰富的内容,易扩展。
-
通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
-
资源服务使用JWT可不依赖认证服务即可完成授权。
缺点:
- JWT令牌较长,占存储空间比较大。
JWT组成
一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。
头部(Header)
头部用于描述关于该JWT的最基本的信息,例如其类型(即JWT)以及签名所用的算法(如HMAC SHA256或RSA)等。这也可以被表示成一个JSON对象。
{
"alg": "HS256",
"typ": "JWT"
}
-
typ
:是类型。 -
alg
:签名的算法,这里使用的算法是HS256算法
我们对头部的json字符串进行BASE64编码(网上有很多在线编码的网站),编码后的字符串如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Base64
是一种基于64个可打印字符来表示二进制数据的表示方法。由于2的6次方等于64,所以每6个比特为一个单元,对应某个可打印字符。三个字节有24个比特,对应于4个Base64单元,即3个字节需要用4个可打印字符来表示。JDK 中提供了非常方便的 BASE64Encoder
和 BASE64Decoder
,用它们可以非常方便的完成基于 BASE64 的编码和解码。
负载(Payload)
第二部分是负载,就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分:
- 标准中注册的声明(建议但不强制使用)
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
- 公共的声明
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
- 私有的声明
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
这个指的就是自定义的claim。比如下面那个举例中的name都属于自定的claim。这些claim跟JWT标准规定的claim区别在于:JWT规定的claim,JWT的接收方在拿到JWT之后,都知道怎么对这些标准的claim进行验证(还不知道是否能够验证);而private claims不会验证,除非明确告诉接收方要对这些claim进行验证以及规则才行。
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
其中sub
是标准的声明,name
是自定义的声明(公共的或私有的)
然后将其进行base64编码,得到Jwt的第二部分:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbWVzIiwiYWRtaW4iOnRydWV9
提示:声明中不要放一些敏感信息。
签证、签名(signature)
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
-
header (base64后的)
-
payload (base64后的)
-
secret(盐,一定要保密)
这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分:
8HI-Lod0ncfVDnbKIPJJqLH998duF9DSDGkx3gRPNVI
将这三部分用.连接成一个完整的字符串,构成了最终的jwt:
eyJhbGciOiJIUzI1NiIsInR9cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.8HI-Lod0ncfVDnbKIPJJqLH998duF9DSDGkx3gRPNVI
注意:secret
是保存在服务器端的,jwt
的签发生成也是在服务器端的,secret
就是用来进行jwt
的签发和jwt
的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret
, 那就意味着客户端是可以自我签发jwt
了。
JJWT简介
什么是JJWT
JJWT是一个提供端到端的JWT创建和验证的Java库。永远免费和开源(Apache License,版本2.0),JJWT很容易使用和理解。它被设计成一个以建筑为中心的流畅界面,隐藏了它的大部分复杂性。
规范官网:https://jwt.io/
SpringSecurity整合JWT
SpringSecurity整合JWT最终达到的效果:
当用户首次登录的时候,输入用户名和密码走正常的登录逻辑,到数据库中根据用户名找到用户的密码信息,然后比对密码是否匹配。若匹配,先将这个用户存入Security的安全上下文holder中,然后利用JWT工具类生成一个token,返回给客户端。
接下来,用户每次请求,都需要在请求头header中携带一个token,这个token首先会进入我们自定义的Jwt登录认证过滤器,从请求头中获取token,利用Jwt工具类解析token,如果能获取到用户名,则用户认证成功,执行下一个过滤器。否则用自定义的拒绝访问类返回权限不足,或者直接用自定义的认证失败类返回token失效或者请先登录。
Application.properties配置
# 应用名称
spring.application.name=springsecurityforjwt
# 应用服务 WEB 访问端口
server.port=8080
# Jwt盐
jwt.secret=******
# 请求头的key value为token
jwt.tokenHeader=Authorization
# 过期时间7天
jwt.expiration=604800
# token的头部
jwt.tokenHead=Bearer
# 配置freemarker视图的位置
spring.freemarker.template-loader-path=classpath:/templates/
# 配置freemarker后缀
spring.freemarker.suffix=.ftl
spring.freemarker.charset=utf-8
JWT工具类
package com.zc.springsecurityforjwt.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtTokenUtil {
private static final String CLAIM_KEY_USERNAME="sub";
private static final String CLAIM_KEY_CREATED="created";
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Value("${jwt.expiration}")
private long expiration;
public String getSecret() {
return secret;
}
public String getTokenHead() {
return tokenHead;
}
public long getExpiration() {
return expiration;
}
/**
* 生成token
* @param claims claims
* @return token
*/
private String generateToken(Map claims)
{
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS256,secret)
.compact();
}
/**
* 根据用户信息生成token
*/
public String generateToken(UserDetails userDetails) {
Map claims = new HashMap<>();
claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
/**
* 从token中获取负载
* @param token token
* @return claims
*/
public Claims getClaimsFromToken(String token)
{
Claims claims = null;
try {
claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
throw new RuntimeException("JWT格式验证失败:{}");
}
return claims;
}
/**
* 从token中获取登录用户名
* @param token
* @return
*/
public String getUsernameFromToken(String token)
{
String username;
try {
Claims claims = getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 生成过期时间
* @return expiration
*/
public Date generateExpirationDate() {
return new Date(System.currentTimeMillis()+expiration*1000);
}
/**
* 验证token是否还有效
* @param token
*客户端传入的token
* @param userDetails 从数据库中查询出来的用户信息
*/
public boolean validateToken(String token, UserDetails userDetails)
{
String username = getUsernameFromToken(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
/**
* 判断token是否已经失效
*/
public boolean isTokenExpired(String token) {
Date expiredDate = getExpiredDateFromToken(token);
return expiredDate.before(new Date());
}
/**
* 从token中获取过期时间
*/
public Date getExpiredDateFromToken(String token) {
return getClaimsFromToken(token).getExpiration();
}
/**
* 当原来的token没过期时是可以刷新的
*
* @param oldToken 带tokenHead的token
*/
public String refreshHeadToken(String oldToken) {
if(StringUtils.isEmpty(oldToken)){
return null;
}
String token = oldToken.substring(tokenHead.length());
if(StringUtils.isEmpty(token)){
return null;
}
//token校验不通过
Claims claims = getClaimsFromToken(token);
if(claims==null){
return null;
}
//如果token已经过期,不支持刷新
if(isTokenExpired(token)){
return null;
}else {
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
}
}
Jwt登录认证过滤器JwtAuthenticationFilter
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private UserService userService;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String authHeader=request.getHeader(tokenHeader);
System.out.println(authHeader);
//存在token
if (null!=authHeader&&authHeader.startsWith(tokenHead))
{
String authToken=authHeader.substring(tokenHead.length());
System.out.println(authToken);
String username = jwtTokenUtil.getUsernameFromToken(authToken);
System.out.println("token中解析到的用户名为:"+username);
//token存在但是未登录
if (null!=username&&null==SecurityContextHolder.getContext().getAuthentication())
{
//登录
User user = userService.findUserByUsername(username);
System.out.println(user);
//判断token是否有效
if (jwtTokenUtil.validateToken(authToken,user))
{
UsernamePasswordAuthenticationToken authenticationToken=
new UsernamePasswordAuthenticationToken(user,null, null);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
}
chain.doFilter(request,response);
}
}
拒绝方法异常RestfulAccessDeniedHandler
@Component
public class RestfulAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
response.setCharacterEncoding("utf-8");
response.setContentType("application/json");
PrintWriter writer = response.getWriter();
RespBean error = RespBean.error("权限不足,联系管理员!");
writer.write(new ObjectMapper().writeValueAsString(error));
error.setCode(403);
writer.flush();
writer.close();
}
}
认证失败异常RestfulAuthorizationEntryPoint
/**
* 当未登录或者token失效时访问接口自定义的返回结果
*/
@Component
public class RestfulAuthorizationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.setCharacterEncoding("utf-8");
response.setContentType("application/json");
PrintWriter writer = response.getWriter();
RespBean bean = RespBean.error("请先登录!");
bean.setCode(401);
writer.write(new ObjectMapper().writeValueAsString(bean));
writer.flush();
writer.close();
}
}
SpringSecurity安全配置类
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserService userService;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private RestfulAccessDeniedHandler restfulAccessDeniedHandler;
@Autowired
private RestfulAuthorizationEntryPoint restfulAuthorizationEntryPoint;
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/toLogin","/login","/static/**","/webjars/**");
}
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/login","/static/**","wabjars/**","swagger-resources/**","/v2/api-doc/**").permitAll()
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
//开启跨域访问
.cors()
.and()
//使用jwt,禁用csrf保护
.csrf().disable()
//关闭session存储
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.headers().cacheControl();
//配置自定义过滤器 添加jwt登录授权过滤器
//在过滤器UsernamePasswordAuthenticationFilter之前
http.addFilterBefore(jwtAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);
//添加自定义未授权未登录结果返回
http.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler)
.authenticationEntryPoint(restfulAuthorizationEntryPoint);
}
@Bean
public JwtTokenUtil tokenUtil(){
return new JwtTokenUtil();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
Controller
@RestController
public class LoginController {
@Autowired
UserService userService;
@PostMapping("/login")
public RespBean login(User user, HttpServletRequest request)
{
return userService.login(user,request);
}
@GetMapping("/user/info")
public User getUserInfo(Principal principal)
{
if (null==principal)
return null;
String username = principal.getName();
User user = userService.findUserByUsername(username);
user.setPassword(null);
return user;
}
}
Service实现类
@Service("userService")
public class UserServiceImpl implements UserService{
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Override
public User findUserByUsername(String username) {
System.out.println("findUserByUsername");
User user = userMapper.findUserByUsername(username);
return user;
}
@Override
public RespBean login(User user, HttpServletRequest request) {
//登录
User userDetails = userMapper.findUserByUsername(user.getUsername());
System.out.println("登录用户为:"+user);
if (null==userDetails||passwordEncoder.matches(user.getPassword(),userDetails.getPassword()))
return RespBean.error("用户名或者密码不正确");
if (!userDetails.isEnabled())
return RespBean.error("账户被禁止使用,请联系管理员");
//更新security登录用户对象
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken=
new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities());
String token=jwtTokenUtil.generateToken(userDetails);
Map map=new HashMap<>();
map.put("token",token);
map.put("tokenHead",jwtTokenUtil.getTokenHead());
return RespBean.success("登录成功",map);
}
}
Postman测试
用户首次进行访问登录接口,携带username和password以post方式进行提交。服务器端返回一个token。
因为后端采用的PostMapping所以注意提交方式。
用户访问后端其他接口,需要在请求头中加上token。
因为后端采用的GetMapping所以注意提交方式。