利用token
进行用户身份验证
流程
- 客户端使用用户名和密码请求登录
- 服务端收到登录请求,验证用户名和密码
- 验证成功后,服务端会签发一个
token
,再把这个token
返回给客户端 - 客户端收到
token
后可以把它存储起来,比如放到cookie
中 - 客户端每次向服务端请求资源时需要携带服务端签发的
token
,可以在cookie
或者header
中携带 - 服务端收到请求,然后去验证客户端请求里面带着的
token
,如果验证成功,就向客户端返回请求数据
优点
- 支持跨域访问:
cookie
是无法跨域的,而token
由于没有用到cookie
(前提是将token
放到请求头中),所以跨域后不会存在信息丢失问题 - 无状态:
token
机制在服务端不需要存储session
信息,因为token自身包含了所有登录用户的信息,所以可以减轻服务端压力 - 更适用CDN:可以通过内容分发网络请求服务端的所有资料
- 更适用于移动端:当客户端是非浏览器平台时,
cookie
是不被支持的,此时采用token
认证方式会简单很多 - 无需考虑CSRF:由于不再依赖
cookie
,所以采用token认证方式不会发生CSRF,所以也就无需考虑CSRF的防御
JWT
JWT简介
上述流程当中token
的具体实现方式为JWT
,其全称是JSON Web Token
,官网地址:https://jwt.io/
就是通过JSON
形式作为Web应用中的令牌,用于在各方之间安全地将信息作为JSON
对象传输。在数据传输过程中还可以完成数据加密、签名等相关处理。
通俗地说,JWT的本质就是一个字符串,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个JWT token
,并且这个JWT token
带有签名信息,接收后可以校验是否被篡改,所以可以用于在各方之间安全地将信息作为Json对象传输。
为什么使用JWT?
基于传统的Session认证
1.认证方式
我们知道HTTP本身是一种无状态的协议,这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,认证通过后HTTP协议不会记录下认证后的状态,那么下一次请求时,用户还要再一次进行认证,因为根据HTTP协议,我们并不知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在用户首次登录成功后,在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie
,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的基于session
认证的过程。
2.认证流程
3.存在问题
- 每个用户的登录信息都会保存到服务器的
session
中,随着用户的增多,服务器开销会明显增大 - 由于
session
是存在与服务器的物理内存中,所以在分布式系统中,这种方式将会失效。虽然可以将session
统一保存到Redis中,但是这样做无疑增加了系统的复杂性,对于不需要redis的应用也会白白多引入一个缓存中间件 - 对于非浏览器的客户端、手机移动端等不适用,因为
session
依赖于cookie
,而移动端经常没有cookie
- 因为
session
认证本质基于cookie
,所以如果cookie
被截获,用户很容易收到跨站请求伪造攻击。并且如果浏览器禁用了cookie
,这种方式也会失效 - 前后端分离系统中更加不适用,后端部署复杂,前端发送的请求往往经过多个中间件到达后端,
cookie
中关于session
的信息会转发多次 - 由于基于Cookie,而cookie无法跨域,所以session的认证也无法跨域,对单点登录不适用
基于JWT认证
1.认证流程
- 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口,这个过程一般是一个
POST
请求。建议的方式是通过SSL加密的传输(HTTPS),从而避免敏感信息被嗅探; - 后端核对用户名和密码成功后,将包含用户信息的数据作为JWT的Payload,将其与JWT Header分别进行Base64编码拼接后签名,形成一个
JWT Token
,形成的JWT Token
就是一个如同lll.zzz.xxx
的字符串; - 后端将
JWT Token
字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在浏览器的localStorage
或sessionStorage
中,退出登录时删除保存的JWT Token
即可; - 前端在每次请求时将
JWT Token
放入HTTP请求头中的Authorization
属性中(解决XSS和XSRF问题); - 后端检查前端传过来的
JWT Token
,验证其有效性,比如检查签名是否正确、是否过期、token的接收方是否是自己等等; - 验证通过后,后端解析出
JWT Token
中包含的用户信息,进行其他逻辑操作(一般是根据用户信息得到权限等),返回结果
2.优势
- 简洁:
JWT Token
数据量小,传输速度也很快 - 因为JWT Token是以JSON加密形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持
- 不需要在服务端保存会话信息,也就是说不依赖于cookie和session,所以没有了传统session认证的弊端,特别适用于分布式微服务
- 单点登录友好:使用Session进行身份认证的话,由于cookie无法跨域,难以实现单点登录。但是,使用token进行认证的话, token可以被保存在客户端的任意位置的内存中,不一定是cookie,所以不依赖cookie,不会存在这些问题
- 适合移动端应用:使用Session进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到Cookie(需要 Cookie 保存 SessionId),所以不适合移动端
因为这些优势,目前无论单体应用还是分布式应用,都更加推荐用JWT token的方式进行用户认证
JWT结构
JWT由3部分组成:标头(
Header
JWT头是一个描述JWT元数据的JSON对象,alg属性表示签名使用的算法,默认为HMAC SHA256(写为HS256);typ属性表示令牌的类型,JWT令牌统一写为JWT。最后,使用Base64 URL算法将上述JSON对象转换为字符串保存
{ "alg": "HS256", "typ": "JWT"}
Payload
有效载荷部分,是JWT的主体内容部分,也是一个JSON对象,包含需要传递的数据。 JWT指定七个默认字段供选择
iss:发行人exp:到期时间sub:主题aud:用户nbf:在此之前不可用iat:发布时间jti:JWT ID用于标识该JWT
这些预定义的字段并不要求强制使用。除以上默认字段外,我们还可以自定义私有字段,一般会把包含用户信息的数据放到payload中,如下例:
{ "sub": "1234567890", "name": "Helen", "admin": true}
请注意,默认情况下JWT是未加密的,因为只是采用base64算法,拿到JWT字符串后可以转换回原本的JSON数据,任何人都可以解读其内容,因此不要构建隐私信息字段,比如用户的密码一定不能保存到JWT中,以防止信息泄露。JWT只是适合在网络中传输一些非敏感的信息
Signature
签名哈希部分是对上面两部分数据签名,需要使用base64编码后的header和payload数据,通过指定的算法生成哈希,以确保数据不会被篡改。首先,需要指定一个密钥(secret)。该密码仅仅为保存在服务器中,并且不能向用户公开。然后,使用header中指定的签名算法(默认情况下为HMAC SHA256)根据以下公式生成签名:
HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload),secret)
在计算出签名哈希后,JWT头,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用.
分隔,就构成整个JWT对象。
注意JWT每部分的作用,在服务端接收到客户端发送过来的JWT token之后:
header
和payload
可以直接利用base64解码出原文,从header
中获取哈希签名的算法,从payload
中获取有效数据signature
由于使用了不可逆的加密算法,无法解码出原文,它的作用是校验token有没有被篡改。服务端获取header
中的加密算法之后,利用该算法加上secretKey
对header
、payload
进行加密,比对加密后的数据和客户端发送过来的是否一致。注意secretKey
只能保存在服务端,而且对于不同的加密算法其含义有所不同,一般对于MD5类型的摘要加密算法,secretKey
实际上代表的是盐值
使用JWT
在实际的SpringBoot
项目中,一般我们可以用如下流程做登录:
- 在登录验证通过后,给用户生成一个对应的随机token(注意这个token不是指jwt,可以用uuid等算法生成),然后将这个token作为key的一部分,用户信息作为value存入Redis,并设置过期时间,这个过期时间就是登录失效的时间;
- 将第1步中生成的随机token作为JWT的payload生成JWT字符串返回给前端;
- 前端之后每次请求都在请求头中的
Authorization
字段中携带JWT字符串; - 后端定义一个拦截器,每次收到前端请求时,都先从请求头中的
Authorization
字段中取出JWT字符串并进行验证,验证通过后解析出payload中的随机token,然后再用这个随机token得到key,从Redis中获取用户信息,如果能获取到就说明用户已经登录。
public class JwtUtils { public static final long EXPIRE_TIME = 60L* 60 * 1000*25*365; public static final String SECRET = "SECRET"; //签发token public static String sign(User user){ Date expireDate = new Date(System.currentTimeMillis() + EXPIRE_TIME); return JWT.create() .withClaim("account", user.getAccount()) .withClaim("userName", user.getUserName()) .withClaim("empCode", user.getEmpCode()) .withExpiresAt(expireDate) .sign(Algorithm.HMAC256(SECRET)); } //校验token public static boolean verify(String token){ try { JWTVerifier verifier = JWT.require(Algorithm.HMAC256(SECRET)).build(); DecodedJWT decodedJWT = verifier.verify(token); return true; }catch (Exception e){ return false; } } //获取token内的携带的用户名信息 public static String getUserNameByToken(String token){ DecodedJWT decodedJWT = JWT.decode(token); return decodedJWT.getClaim("userName").asString(); } //获取token内的携带的用户信息 public static User getUserByToken(String token){ DecodedJWT decodedJWT = JWT.decode(token); User user = new User(); user.setUserName(decodedJWT.getClaim("userName").asString()); user.setAccount(decodedJWT.getClaim("account").asString()); user.setEmpCode(decodedJWT.getClaim("empCode").asString()); return user; }}
在实际开发中需要用下列手段来增加JWT的安全性:
- 因为JWT是在请求头中传递的,所以为了避免网络劫持,推荐使用
HTTPS
来传输,更加安全- JWT的哈希签名的密钥是存放在服务端的,所以只要服务器不被攻破,理论上JWT是安全的。因此要保证服务器的安全
- JWT可以使用暴力穷举来破解,所以为了应对这种破解方式,可以定期更换服务端的哈希签名密钥(相当于盐值)。这样可以保证等破解结果出来了,你的密钥也已经换了
spring boot实现登录验证
spring boot 通过实现HandlerInterceptor
接口来实现一个拦截器,通过实现WebMvcConfigurer
接口实现一个配置类,在配置类中注入拦截器,最后再通过 @Configuration 注解注入配置.
实现HandlerInterceptor
接口
public class LoginInterceptor implements HandlerInterceptor { private static final Logger logger = LoggerFactory.getLogger(LoginInterceptor.class); /* * 在请求处理之前进行调用(Controller方法调用之前) * 若返回true请求将会继续执行后面的操作 * */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("token"); // 如果不是映射到方法不拦截 直接通过 if (!(handler instanceof HandlerMethod)) { return true; } //验证token if (null == token || "".equals(token) || !JwtUtils.verify(token)) { response.setCharacterEncoding("UTF-8"); response.setContentType("application/json; charset=utf-8"); try (PrintWriter writer = response.getWriter()) { writer.print(APIResult.loginFailResult()); } catch (Exception e) { logger.error("login token error is {}", e.getMessage()); } return false; } //若token验证成功,把用户信息存储在ThreadLocal User user = JwtUtils.getUserByToken(token); UserUtils.setLoginUser(user); return true; } /*** * 请求处理之后进行调用,但是在视图被渲染之前(Controller方法调用之后) */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { System.out.println("执行了拦截器的postHandle方法"); } /*** * 整个请求结束之后被调用,也就是在DispatchServlet渲染了对应的视图之后执行(主要用于进行资源清理工作) */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { //清除线程变量 UserUtils.removeUser(); }}
其中preHandle()
方法在Controller调用之前执行,因此可以用来拦截请求,并进行登录验证,只有通过验证才返回请求数据。
实现WebMvcConfigurer
接口
@Configurationpublic class InterceptorConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { //注册TestInterceptor拦截器 registry.addInterceptor(loginInterceptor()) .excludePathPatterns("/api/login")//添加不拦截的请求路径 .excludePathPatterns("/api/loginBackend") .addPathPatterns("/**");//添加需要拦截的路径 } @Bean public LoginInterceptor loginInterceptor(){ return new LoginInterceptor(); }}
将前面自定义的LoginInterceptor
拦截器注册到了拦截器列表中,并且指明了拦截哪些访问路径,不拦截哪些访问路径。再以 @Configuration 注解将配置注入。
public abstract class UserUtils { //线程变量,存放user实体类信息,即使是静态的与其他线程也是隔离的 private static final ThreadLocal<User> userThreadLocal = new ThreadLocal<>(); //获取当前登录用户 public static User getLoginUser() { return userThreadLocal.get(); } public static void setLoginUser(User user) { userThreadLocal.set(user); } public static void removeUser(){ userThreadLocal.remove(); }}