1、Token认证
随着 Restful API、微服务的兴起,基于 Token 的认证现在已经越来越普遍。基于token的用户认证是一种服务端无状态的认证方式,所谓服务端无状态指的token本身包含登录用户所有的相关数据,而客户端在认证后的每次请求都会携带token,因此服务器端无需存放token数据。
当用户认证后,服务端生成一个token发给客户端,客户端可以放到 cookie 或 localStorage 等存储中,每次请求时带上 token,服务端收到token通过验证后即可确认用户身份。
分布式单点登录三种常见方式:(SSO)
-
第一种,session广播机制实现。(把session复制到另一台服务器中)
- 缺点:模块较多时,拷贝session比较浪费资源;比如 中间会存在多份一样的数据 ;session默认过期时间30分钟,过期需要重新登录
-
第二种,使用cookie+redis实现。
-
cookie客户端技术:存在浏览器中,每次发送请求,带着cookie值进行发送
-
redis,读取速度快,基于key-value存储(keys *)
-
用户登录后,把数据分别放到两个地方cookie、redis
- ① redis,在key里生成唯一随机值(ip、用户id、uuid) ,在value里放用户数据
- ② cookie,把redis里面生成key值放到cookie里面。
-
访问项目其他模块时,发送请求带着cookie进行发送,然后其他模块去获取cookie值,也就是拿着cookie去redis中查询,如果能查到数据表示这个用户已登录。
-
-
第三种,token认证(按照一定规则生成字符串,字符串可以包含用户信息) ,jwt
2、JWT 概述
JWT(全称:JSON Web Token),通过数字签名的方式,以JSON对象作为载体,在不同的服务终端之间安全的传输信息。JWT 是实现Token无状态会话认证技术的一种标准。
JWT作用:通常用于web应用程序的 身份验证 和 鉴权 。JWT会在用户登录后生成一个令牌,并在后续每个请求中将该令牌作为身份凭证发送给服务器,系统在处理用户请求之前,都要先进行JWT的安全校验,通过之后再进行相应的业务处理。
3、JWT的组成
JWT令牌由Header、Payload、Signature三部分组成,每部分字符串中间用.
拼接。JWT令牌的最终格式是这样的: xxx.yyy.zzz
。
# Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjpbeyJ1cmwiOiJodHRwczovL3Rvb2x0dC5jb20ifV0sImlhdCI6MTY0NjExMDgwNSwiZXhwIjoyNTU2MTE1MTk5LCJhdWQiOiIiLCJpc3MiOiJ0b29sdHQuY29tIiwic3ViIjoiIn0.NhUwqiPfYey9pKHSfrG-ptqEOamIQFK3-K7IrTeBFYU
3.1 Header(标头)
Header(标头),通常由两部分组成:令牌的类型 和 所用的加密算法,然后将该JSON对象数据进行Base64 URL编码,得到的字符串就是JWT令牌的第一部分。
{"type":"JWT", # 表示要生成JWT类型的token"alg":"HS256" # 加密算法
}
3.2 Payload(载荷)
Payload(载荷),有效数据存储区,主要定义自定义字段和内置字段数据。通常会把用户信息和令牌过期时间放在这里,同样也是一个JSON对象,里面的key和value可随意设置,然后经过Base64 URL编码后得到JWT令牌的第二部分,由于这个部分是没有加密的(因为Base64是编码,可以直接解码),建议只存放一些非敏感信息。
{"sub": "1234567890","name": "aopmin","admin": true
}
Payload的内置字段:
- iss(Issuer):令牌的签发者
- sub(Subject):所面向的用户或实体
- aud(Audience):令牌的接收者
- exp(Expiration Time):令牌的过期时间(以UNIX时间戳表示)
- nbf(Not Before):令牌的生效时间(以UNIX时间戳表示)
- iat(Issued At):令牌的签发时间(以UNIX时间戳表示)
- jti(JWT ID):令牌的唯一标识符
3.3 Signature(签名)
Signature(签名), 使用头部Header定义的加密算法,对前两部分Base64编码拼接的结果进行加密,加密时的秘钥服务私密保存,加密后的结果在通过Base64Url编码得到JWT令牌的第三部分。
签名的作用:防止JWT令牌被篡改。
//将头部和载荷base64编码后的数据进行拼接
var encodeStr = base64UrlEncode(header) + "." + base64UrlEncode(payload);
//使用头部定义的算法,对拼接后的数据进行加密 //secret 盐值、秘钥
var signature = HMACSHA256(encodeStr,secret);
4、JWT的使用
1、创建springboot项目,项目名:springboot-jwt
2、向pom.xml中,导入如下依赖:
<dependencies><!-- springmvc --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- junit --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId></dependency><!-- jwt --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version></dependency><!-- lombok --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency>
</dependencies>
注:如果使用JDK8以后的版本,jwt需要引入额外的4个依赖 jaxb-api、jaxb-impl、jaxb-core、activation。
3、使用JWT生成Token:
package com.baidou.test;import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;import java.util.Date;
import java.util.UUID;/*** 使用JWT生成token和验证token** @author 白豆五* @version 2023/06/19* @since JDK8*/
public class JwtTest {/*** 测试使用JWT生成令牌* 应用场景:用户登录生成token*/@Testpublic void testCreatJwt() {String secretKey = "aopmin"; //秘钥// 使用Jwts工具类构建一个令牌String token = Jwts.builder()// 1.设置JWT头部信息(类型和加密算法).setHeaderParam("typ", "JWT").setHeaderParam("alg", "HS256")// 2.设置JWT载荷数据.setId(UUID.randomUUID().toString()) //内置字段jti:表示唯一ID.setSubject("all") //内置字段sub:面向所有用户.setIssuedAt(new Date()) //内置字段ita:token创建时间.setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)) //内置字段exp:token过期时间,30分钟.claim("username", "aopmin") //自定义字段,kv格式.claim("userId", "1001") //自定义字段// 3.设置JWT签名信息(加密算法,秘钥).signWith(SignatureAlgorithm.HS256, secretKey).compact(); //最后调用compact()方法生成最终的tokenSystem.out.println("token = " + token);//由于使用UUID生成唯一标识,所以每次生成的token都不一样}
}
输出结果:
token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI5MmJkNTQxOC1lNjBkLTRiMjYtYmVkNS01NDlkZmYyOTliZmEiLCJzdWIiOiJhbGwiLCJpYXQiOjE2ODcxOTI4MTQsImV4cCI6MTY4NzE5NDYxNCwidXNlcm5hbWUiOiJhb3BtaW4iLCJ1c2VySWQiOiIxMDAxIn0.H6aI4ozESqiamOfY9NxunnEs0y3AhOTHcXsFFYmPut4
在线token解密:https://tooltt.com/jwt-decode/
4、使用JWT验证Token:
/*** 测试使用JWT验证令牌* 应用场景:用户登录后请求系统携带token令牌,系统对token进行验证,判断是否合法* 令牌解析失败的情况:* 1.令牌过期* 2.令牌被篡改* 结论:一个合法的Tokn令牌一定是未过期、未被篡改的令牌*/
@Test
public void testcheckToken() {// 秘钥String secretKey = "aopmin";// 待验证的tokenString tokenStr = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI5MmJkNTQxOC1lNjBkLTRiMjYtYmVkNS01NDlkZmYyOTliZmEiLCJzdWIiOiJhbGwiLCJpYXQiOjE2ODcxOTI4MTQsImV4cCI6MTY4NzE5NDYxNCwidXNlcm5hbWUiOiJhb3BtaW4iLCJ1c2VySWQiOiIxMDAxIn0.H6aI4ozESqiamOfY9NxunnEs0y3AhOTHcXsFFYmPut4";// 通过密钥验证签名是否被篡改Jws<Claims> claimsJws = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(tokenStr);// 获取头JwsHeader header = claimsJws.getHeader();// 获取载荷Claims body = claimsJws.getBody();// 获取签名String signature = claimsJws.getSignature();System.out.println("头信息:" + header);System.out.println("载荷信息:" + body);System.out.println("签名信息:" + signature);
}
输出结果:
头信息:{typ=JWT, alg=HS256}
载荷信息:{jti=92bd5418-e60d-4b26-bed5-549dff299bfa, sub=all, iat=1687192814, exp=1687194614, username=aopmin, userId=1001}
签名信息:H6aI4ozESqiamOfY9NxunnEs0y3AhOTHcXsFFYmPut4
5、SpringBoot+JWT快速整合
5.1 用户登录生成Token
1、创建实体类
package com.baidou.pojo;import lombok.Data;import java.io.Serializable;/*** 用户实体** @author 白豆五* @version 2023/06/20* @since JDK8*/
@Data
public class User implements Serializable {private String id;private String userName;private String passWord;/*** token字符串*/private String token;
}
2、创建JWT工具类
package com.baidou.utils;import cn.hutool.core.util.StrUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.web.bind.annotation.GetMapping;import javax.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.UUID;/*** JWT工具类** @author 白豆五* @version 2023/06/20* @since JDK8*/
public class JwtUtil {//过期时间:1个小时public static final long EXPIRE = 1000 * 60 * 60 * 1;//秘钥public static final String APP_SECRET = "aopmin";/*** 创建Token** @param id 用户ID* @param userName 用户名称* @return jwtToken*/public static String createToken(String id, String userName) {// 使用Jwts工具类构建一个令牌String jwtToken = Jwts.builder()// 1.设置JWT头部信息(类型和加密算法).setHeaderParam("typ", "JWT").setHeaderParam("alg", "HS256")// 2.设置JWT载荷数据.setId(UUID.randomUUID().toString()) //内置字段jti:表示唯一ID.setSubject("all") //内置字段sub:面向所有用户.setIssuedAt(new Date()) //内置字段ita:token创建时间.setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) //内置字段exp:token过期时间,token过期时间30分钟.claim("username", userName) //自定义字段,kv格式.claim("userId", id) //自定义字段// 3.设置JWT签名(加密算法,秘钥).signWith(SignatureAlgorithm.HS256, APP_SECRET).compact(); //最后调用compact()方法生成最终的tokenreturn jwtToken;}/*** 判断Token是否、有效** @param jwtToken token* @return true:token有效 、false:token失效*/public static boolean checkToken(String jwtToken) {// 非空判断if (StrUtil.isBlank(jwtToken)) {return false;}try {// 通过密钥验证签名是否被篡改Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);} catch (Exception e) {e.printStackTrace();return false;}return true;}/*** 判断Token是否存在、有效** @param request 从请求头中拿token* @return true:token有效 、false:token失效*/public static boolean checkToken(HttpServletRequest request) {try {String jwtToken = request.getHeader("token");if (StrUtil.isBlank(jwtToken)) {return false;}// 通过密钥验证签名是否被篡改Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);} catch (Exception e) {e.printStackTrace();return false;}return true;}/*** 根据Token获取用户ID** @param request 从请求头中拿token* @return userID*/public static String getUserIdByJwtToken(HttpServletRequest request) {// 从请求头中拿tokenString jwtToken = request.getHeader("token");// 非空判断if (StrUtil.isBlank(jwtToken)) {return "";}// 通过密钥验证签名是否被篡改Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);// 获取载荷信息Claims claims = claimsJws.getBody();// 用户IDreturn (String) claims.get("userId");}}
3、创建控制器类:UserController
package com.baidou.controller;import cn.hutool.core.util.StrUtil;
import com.baidou.pojo.User;
import com.baidou.utils.JwtUtil;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;/*** 用户接口** @author 白豆五* @version 2023/06/20* @since JDK8*/
@RestController //前后端分离使用json格式
@RequestMapping("user")
public class UserController {// region 登录相关/*** 用户登录接口** @param user* @return*/@PostMapping("/login")public User login(@RequestBody User user) {// 把账号密码直接写死(不让他走数据库)String userName = "aopmin";String passWord = "123456";// 非空校验if (StrUtil.isAllBlank(user.getUserName(), user.getPassWord())) {return null;}// 如果用户名和密码都正确,创建tokenif (userName.equals(user.getUserName()) && passWord.equals(user.getPassWord())) {// 创建Token:token保存到user对象中user.setToken(JwtUtil.createToken(user.getId(), user.getUserName()));return user;}// 用户名和密码不正确,返回nullreturn null;}/*** 验证Token是否过期** @param token 用户token* @return true未过期、false已过期*/@GetMapping("/check_token")public boolean checkToken(String token) {return JwtUtil.checkToken(token);}/*** 验证Token是否过期 --- 前端把token放到请求头中** @param request 从请求头中拿token* @return true未过期、false已过期*/@GetMapping("/check_token2")public boolean checkToken(HttpServletRequest request) {return JwtUtil.checkToken(request);}// endregion
}
4、启动项目,使用Apifox测试接口
测试登录接口:http://localhost:8080/user/login
token:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4MzcyNDY3Zi0xOGY4LTQ0YjEtYTIzMi0zNjcwZTk3ODZjZDYiLCJzdWIiOiJhbGwiLCJpYXQiOjE2ODcxOTgxNzIsImV4cCI6MTY4NzIwMTc3MiwidXNlcm5hbWUiOiJhb3BtaW4iLCJ1c2VySWQiOiIxMDAxIn0.8JZMuIeqf1VXuz6-SSDDD48hGRGmjDUNI9xjJd0RjL8
测试验证token接口:http://localhost:8080/user/check_token
测试验证token接口(前端把token放到请求头中):http://localhost:8080/user/check_token2
5.2 跨域配置
前后端会存在跨域问题。
在发送请求时,如果出现以下任意一种情况,那么它就是跨域请求:
-
协议不同,如 http 、https;
-
域名不同,如 www.taobao.com、www.jd.com、www.baidu.com
-
端口不同,如 http:localhost:8080、http:localhost:8081
后端解决方案:
package com.baidou.config;import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;/*** 跨域配置** @author 白豆五* @version 2023/06/20* @since JDK8*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {/*** 添加跨域配置* @param registry 注册器*/@Overridepublic void addCorsMappings(CorsRegistry registry) {// 覆盖所有请求registry.addMapping("/**") // 配置可以跨域的路径,/**表示匹配所有请求路径.allowedOrigins("*") // 允许所有的请求,也可以指定具体的请求,例如 allowedOrigins("http://example.com").allowedHeaders("*") // 允许所有请求头访问,也可以指定具体的请求头访问,例如 allowedHeaders("Content-Type", "Authorization").allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD") // 允许的HTTP方法,根据需要添加或删除特定的HTTP方法.maxAge(3600); // 预检请求的缓存时间,单位为秒}
}
5.3 使用拦截器验证Token
1、创建验证token的拦截器
package com.baidou.interceptor;import com.baidou.utils.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;/*** Token拦截器 ———— 验证Token** @author 白豆五* @version 2023/06/20* @since JDK8*/
@Slf4j
@Configuration
public class TokenInterceptor implements HandlerInterceptor {// 在控制器请求处理方法被调用之前执行@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {log.info("验证token的拦截器执行了,token:{}",request.getHeader("token"));// 要求前端必须把token放到请求头中if (!JwtUtil.checkToken(request)) {return false; //验证失败}return true; //放行}
}
2、创建WebConfig配置类,注册拦截器
package com.baidou.config;import com.baidou.interceptor.TokenInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;/*** SpringMVC配置类** @author 白豆五* @version 2023/06/20* @since JDK8*/
@Configuration
public class WebConfig implements WebMvcConfigurer {@Autowiredprivate TokenInterceptor tokenInterceptor;// 注册拦截器@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(tokenInterceptor).addPathPatterns("/**"); // 添加拦截器,并指定要拦截的路径}
}
3、编写测试接口:
/*** 从token中获取用户名** @param request* @return*/
@GetMapping("/getName")
public String getUserName(HttpServletRequest request) {// 从请求头中拿tokenString token = request.getHeader("token");// 非空判断if (StrUtil.isBlank(token)) {return "";}// 通过密钥验证签名是否被篡改Jws<Claims> claimsJws = Jwts.parser().setSigningKey("aopmin").parseClaimsJws(token);// 获取载荷信息Claims claims = claimsJws.getBody();// 用户IDreturn (String) claims.get("username");
}
4、测试:http://localhost:8080/user/getName
6、加密算法(扩展)
6.1 常用的加密算法
5.2 密码加密技术选型
6.2.1 MD5加密方式
MD5一种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值(hash value),用于确保信息传输完整一致。MD5由美国密码学家罗纳德·李维斯特(Ronald Linn Rivest)设计,于1992年公开,用以取代MD4算法。这套算法的程序在 RFC 1321 标准中被加以规范。1996年后该算法被证实存在弱点,可以被加以破解,对于需要高度安全性的数据,专家一般建议改用其他算法,如SHA-2。2004年,证实MD5算法无法防止碰撞(collision),因此不适用于安全性认证,如SSL公开密钥认证或是数字签名等用途。
示例:
package com.baidou.test;import org.springframework.util.DigestUtils;/*** 测试MD5加密算法** @author 白豆五* @version 2023/06/20* @since JDK8*/
public class MD5Test {public static void main(String[] args) {// 使用spring框架提供的DegestUtils工具类实现MD5加密String s1 = DigestUtils.md5DigestAsHex("hello".getBytes());String s2 = DigestUtils.md5DigestAsHex("hello".getBytes());System.out.println(s1); // 5d41402abc4b2a76b9719d911017c592System.out.println(s2); // 5d41402abc4b2a76b9719d911017c592System.out.println(s1.equals(s2)); // true}
}
注意:md5对相同的内容加密,每次加密后的密文是相同的,所以不太安全。
6.2.2 MD5+盐
基于md5+随机字符串进行手动加密,增加破解md5的复杂度。(这种方式盐需要保存到表中)
在md5的基础上进行手动加盐(salt)处理:
package com.baidou.test;import org.springframework.util.DigestUtils;/*** 测试:MD5+盐方式** @author 白豆五* @version 2023/06/20* @since JDK8*/
public class Md5SaltTest {public static void main(String[] args) {// 盐值String salt = "2023-04-29"; // 明文密码String pwd = "admin";// MD5加密的密码String md5Pwd = DigestUtils.md5DigestAsHex(pwd.getBytes());// MD5+盐加密的密码String md5Pwd2 = DigestUtils.md5DigestAsHex((pwd + salt).getBytes());System.out.println(md5Pwd); // 21232f297a57a5a743894a0e4a801fc3System.out.println(md5Pwd2); // 1676be8379ca0a334d035cbd32cb24de}
}
注意:这种方式,同样的密码,如果盐的值是随机字符串,那么加密多次的密码是不相同的;
6.2.3 Bcrypt加密方式
在用户模块中,对于用户密码的保护,我们通常对密码进行加密,然后存放在数据库中,在用户进行登录的时候,将其输入的密码进行加密然后与数据库中存放的密文进行比较,以验证用户密码是否正确。 目前,MD5和BCrypt比较流行。相对来说,BCrypt比MD5更安全。
BCrypt官网:http://www.mindrot.org/projects/jBCrypt/
1、从官网下载源码,将源码类BCrypt拷贝到工程中;(当然Hutool工具类中也提供了BCrypt加密)(盐不需要保存表中)
2、新建测试类,main方法中编写代码,实现对密码的加密;
3、BCrypt不支持反运算,只支持密码校验。
BCrypt常用工具方法:
- gensalt():生成盐;(随机字符串)
- hashpw(明文密码,盐):加密方法;
- checkpw(明文密码, 密文密码):验证方法;
示例:
package com.baidou.test;/*** 测试Bcrypt加密方式** @author 白豆五* @version 2023/06/20* @since JDK8*/
public class BcryptTest {private static String pwdEncrypt = null; //模拟数据库表中的密码public static void main(String[] args) {// 模拟用户注册register("123456");// 模拟用户登录checkPwd("123456");}/*** 用户注册方法** @param pwd 明文密码* @return 盐*/public static void register(String pwd) {// 生成盐值String salt = BCrypt.gensalt();// 加密pwdEncrypt = BCrypt.hashpw(pwd, salt);System.out.println("盐: " + salt + ",加密后密码: " + pwdEncrypt);}/*** 模拟用户登录* @param pwd 用户输入的密码*/public static void checkPwd(String pwd) {// 解密boolean isMatch = BCrypt.checkpw(pwd, pwdEncrypt);if (isMatch) {System.out.println("密码正确!");} else {System.out.println("密码错误!");}}
}