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
*/
@Test
public 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()方法生成最终的token
System.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";
// 待验证的token
String 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()方法生成最终的token
return 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) {
// 从请求头中拿token
String jwtToken = request.getHeader("token");
// 非空判断
if (StrUtil.isBlank(jwtToken)) {
return "";
}
// 通过密钥验证签名是否被篡改
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
// 获取载荷信息
Claims claims = claimsJws.getBody();
// 用户ID
return (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;
}
// 如果用户名和密码都正确,创建token
if (userName.equals(user.getUserName()) && passWord.equals(user.getPassWord())) {
// 创建Token:token保存到user对象中
user.setToken(JwtUtil.createToken(user.getId(), user.getUserName()));
return user;
}
// 用户名和密码不正确,返回null
return 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 注册器
*/
@Override
public 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 {
// 在控制器请求处理方法被调用之前执行
@Override
public 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 {
@Autowired
private TokenInterceptor tokenInterceptor;
// 注册拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenInterceptor)
.addPathPatterns("/**"); // 添加拦截器,并指定要拦截的路径
}
}
3、编写测试接口:
/**
* 从token中获取用户名
*
* @param request
* @return
*/
@GetMapping("/getName")
public String getUserName(HttpServletRequest request) {
// 从请求头中拿token
String token = request.getHeader("token");
// 非空判断
if (StrUtil.isBlank(token)) {
return "";
}
// 通过密钥验证签名是否被篡改
Jws<Claims> claimsJws = Jwts.parser().setSigningKey("aopmin").parseClaimsJws(token);
// 获取载荷信息
Claims claims = claimsJws.getBody();
// 用户ID
return (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); // 5d41402abc4b2a76b9719d911017c592
System.out.println(s2); // 5d41402abc4b2a76b9719d911017c592
System.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); // 21232f297a57a5a743894a0e4a801fc3
System.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("密码错误!");
}
}
}