目录
1.问题场景
在我们开发网站实现用户登录的过程中, 需要将用户的一些信息存储在服务器中.
传统思路:
- 登陆页面把用户名密码提交给服务器.
- 服务器端验证用户名密码是否正确,并返回校验结果给后端
- 如果密码正确,则在服务器端创建Session .通过Cookie把sessionld返回给浏览器
但是这种方式存在一些问题: 集群环境下无法直接使用Session. 以及当服务器重启后, 保存的信息会丢失
原因分析:
我们开发的项目,在企业中很少会部署在一台机器上,容易发生单点故障.(单点故障: 一旦这台服务器挂了,整个应用都没法访问了). 所以通常情况下,一个Web应用会部署在多个服务器上,通过Nginx等进行负载均衡. 此时,来自一个用户的请求就会被分发到不同的服务器上.
假如我们使用Session进行会话跟踪,我们来思考如下场景:
1. 用户登录 用户登录请求, 经过负载均衡, 把请求转给了第一台服务器, 第一台服务器进行账号密码
验证, 验证成功后,把Session存在了第一台服务器上.
2. 查询操作用户 登录成功之后,携带Cookie(里面有SessionId)继续执行查询操作, 比如查询博客列
表. 此时请求转发到了第二台机器, 第二台机器会先进行权限验证操作 (通过Sessionld验证用户是否
登录), 此时第二台机器上没有该用户的Session, 就会出现问题, 提示用户登录, 这是用户不能忍受的.
接下来我们介绍一种解决方案: 令牌技术
2. 令牌技术
令牌其实就是一个用户身份的标识,名称起的很高大上, 其实本质就是一个字符串.
比如我们出行在外,会带着自己的身份证,需要验证身份时,就掏出身份证
身份证不能伪造,可以辨别真假.
服务器具备生成令牌和验证令牌的能力
我们使用令牌技术, 继续思考上述场景:
1.用户登录用户登录请求, 经过负载均衡, 把请求转给了第一台服务器, 第一台服务器进行账号密码
验证,验证成功后,生成一个令牌, 并返回给客户端.
2.客户端收到令牌之后,把令牌存储起来. 可以存储在Cookie中,也可以存储在其他的存储空间 (比如localStorage)
3.查询操作用户登录成功之后, 携带令牌继续执行查询操作, 比如查询博客列表. 此时请求转发到了
第二台机器, 第二台机器会先进行权限验证操作. 服务器验证令牌是否有效, 如果有效, 就说明用户已经执行了登录操作, 如果令牌是无效的, 就说明用户之前未执行登录操作.
令牌的优缺点
优点:
解决了集群环境下的认证问题
减轻服务器的存储压力(无需在服务器端存储)
缺点:
需要自己实现(包括令牌的生成,令牌的传递, 令牌的校验)
当前企业开发中,解决会话跟踪使用最多的方案就是令牌技术.
3.JWT令牌
令牌本质就是一个字符串,他的实现方式有很多,我们采用一个JWT令牌来实现.
3.1 介绍
JWT全称: JSON Web Token
官网: https://jwt.io/
JSON Web Token(JWT) 是一个开放的行业标准(RFC 7519), 用于客户端和服务器之间传递安全可靠的信息.
其本质是一个token, 是一种紧凑的URL安全方法.
3.2 JWT组成
JWT由三部分组成, 每部分中间使用点 (.) 分隔,比如: aaaa.bbbb.cccc
Header(头部) 头部包括令牌的类型(即JWT) 及使用的哈希算法 (如HMAC SHA256或RSA)
Payload(负载) 负载部分是存放有效信息的地方, 里面是一些自定义内容. 比如:
{"userId":"123","userName":" zhangsan"'}, 也可以存在 jwt 提供的现场字段, 比如exp(过期时间戳)等.
此部分不建议存放敏感信息,因为此部分可以解码还原原始内容.
Signature(签名) 此部分用于防止 jwt 内容被篡改, 确保安全性.
防止被篡改,而不是防止被解析.
JWT之所以安全,就是因为最后的签名. jwt当中任何一个字符被篡改, 整个令牌都会校验失败.
就好比我们的身份证, 之所以能标识一个人的身份, 是因为他不能被篡改, 而不是因为内容加密.(任何人都可以看到身份证的信息, jwt也是)
对上面部分的信息,使用Base64Url进行编码, 合并在一起就是jwt令牌
Base64是编码方式,而不是加密方式
1. 引⼊JWT令牌的依赖
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is
preferred -->
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
2. 使用Jar包中提供的API来完成JWT令牌的生成和校验
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import org.junit.Test;
import javax.crypto.SecretKey;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtUtilsTest {
//设置过期时间1小时
private static final long JWT_EXPIRATION = 60 * 60 * 1000;
//密钥 长度有要求
private static final String secretStr = "CIw6DzttvHA+XnrTa2B1EMhLoai1R0vC6jr0Q6y/qsU=";
//生成 key
private static final Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretStr));
@Test
public void genJwt() {
Map<String,Object> claim = new HashMap<>();
claim.put("id", 2);
claim.put("name","zhangsan");
String token = Jwts.builder()
.setClaims(claim) //自定义内容(负载)
.setExpiration(new Date(System.currentTimeMillis() + JWT_EXPIRATION)) //设置过期时间
.signWith(key) //设置签名
.compact();
//将生成的 token 打印出来
System.out.println(token);
}
//生成密钥
@Test
public void genKey() {
SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
String str = Encoders.BASE64.encode(secretKey.getEncoded());
System.out.println(str);
}
}
注意:对于密钥有长度和内容有要求, 建议使用
io.jsonwebtoken.security.Keys#secretKeyFor(signaturealgalgorithm) 方法来创建一个密钥
//生成密钥
@Test
public void genKey() {
SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
String str = Encoders.BASE64.encode(secretKey.getEncoded());
System.out.println(str);
}
运行程序:
输出的内容, 就是JWT令牌.
通过点(.)对三个部分进行分割, 我们把生成的令牌通过官网进行解析, 就可以看到我们存储的信息了
1. HEADER部分可以看到,使用的算法为HS256.
2. PAYLOAD部分是我们自定义的内容, exp表示过期时间
3. VERIFY SIGNATURE部分是经过签名算法计算出来的, 所以不会解析
校验令牌
完成了令牌的生成,我们需要根据令牌,来校验令牌的合法性(以防客户端伪造)
@Test
public void parseToken() {
String token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiemhhbmdzYW4iLCJpZCI6MiwiZXhwIjoxNzIzMTk1Njc0fQ.xdsDIsJeR24S3XnS16iLA8Jd5hQGK9DPWJ9OuevM3LE";
JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();
Claims claims = build.parseClaimsJws(token).getBody();
System.out.println(claims);
}
运行结果:
令牌解析后, 我们可以看到里面存储的信息,如果在解析的过程当中没有报错,就说明解析成功了
令牌解析时, 也会进行时间有效性的校验, 如果令牌过期了, 解析也会失败.
修改令牌中的任何一个字符, 都会校验失败, 所以令牌无法篡改.
4. 实际使用示例
学习令牌的使用之后,接下来我们通过令牌来完成用户的登录
1.登陆页面把用户名密码提交给服务器.
2.服务器端验证用户名密码是否正确,如果正确,服务器生成令牌, 下发给客户端.
3.客户端把令牌存储起来(此如Cookie, local storage等), 后续请求时, 把token发给服务器
4.服务器对令牌进行校验, 如果令牌正确,进行下一步操作
约定前后端交互接口
4.1 服务器代码示例
创建JWT工具类:
import com.example.blog.constant.Constants;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import java.security.Key;
import java.util.Date;
import java.util.Map;
@Slf4j
public class JwtUtils {
//设置过期时间1小时
private static final long JWT_EXPIRATION = 60 * 60 * 1000;
//密钥 长度有要求
private static final String secretStr = "CIw6DzttvHA+XnrTa2B1EMhLoai1R0vC6jr0Q6y/qsU=";
//生成 key
private static final Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretStr));
/**
* 生成token
*/
public static String genJwtToken(Map<String, Object> claim) {
//签名算法
String token = Jwts.builder()
.setClaims(claim) //自定义内容(负载)
.setExpiration(new Date(System.currentTimeMillis() + JWT_EXPIRATION)) //设置过期时间
.signWith(key) //设置签名
.compact();
//将生成的 token 返回
return token;
}
/**
* 校验token
* Claims 为null: 表示校验失败
*/
public static Claims parseToken(String token) {
//创建解析器, 设置签名密钥
JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();
Claims claims = null;
try {
//解析token
claims = build.parseClaimsJws(token).getBody();
} catch (Exception e) {
//签名验证失败
log.error("解析token失败, token:{}", token);
return null;
}
return claims;
}
/**
* 从token中获取用户id
* @param token
* @return
*/
public static Integer getIdByToken(String token) {
Claims claims = parseToken(token);
if (claims != null) {
Integer userId = (Integer) claims.get(Constants.TOKEN_ID);
if(userId > 0) {
return userId;
}
}
return null;
}
}
创建UserController:
@RequestMapping("/user")
@RestController
@Slf4j
public class UserController {
@Autowired
private UserService userService;
@RequestMapping("/login")
public Result login(String username, String password) {
log.info("用户登录: userName:" + username + " , password:" + password);
//1.参数校验
//2.校验密码是否正确
//3.密码正确, 返回token
//4.密码错误, 返回错误信息
if(!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
log.error("账号或密码不能为空");
return Result.fail("账号或密码不能为空!");
}
//从数据库中查找用户
UserInfo userInfo = userService.selectByName(username);
if(userInfo == null) {
log.error("用户不存在");
return Result.fail("用户不存在");
}
if(!password.equals(userInfo.getPassword())) {
log.error("密码错误");
return Result.fail("密码错误!");
}
//密码正确, 返回token
Map<String, Object> claim = new HashMap<>();
claim.put(Constants.TOKEN_ID,userInfo.getId());
claim.put(Constants.TOKEN_USERNAME, userInfo.getUserName());
String token = JwtUtils.genJwtToken(claim);
log.info("用户登录成功, token:{}", token);
return Result.success(token);
}
/**
* 获取当前登录用户的信息
* @return
*/
@RequestMapping("/getUserInfo")
public UserInfo getLoginUserInfo(HttpServletRequest request) {
//获取token
String token = request.getHeader(Constants.REQUEST_HEADER_TOKEN);
//从 token 中获取登录用户 id
Integer userId = JwtUtils.getIdByToken(token);
if(userId == null) {
//用户未登录
return null;
}
UserInfo userInfo = userService.selectById(userId);
return userInfo;
}
}
4.2 客户端代码示例
前端收到token之后,保存在localstorage中
function login() {
$.ajax({
type:'post',
url:'user/login',
data: {
username: $('#username').val(),
password: $('#password').val()
},
success: function(result) {
if(result.code == 'SUCCESS' && result.data!=null) {
//后端处理成功, 将token存储起来
localStorage.setItem("user_token", result.data);
//页面跳转
location.assign("blog_list.html");
}else {
alert(result.errMsg);
}
}
});
}
local storage相关操作
1.存储数据
localStorage.setItem("user_token","value");
2.读取数据
localStorage.getItem("user_token");
3.删除数据
localStorage.removeItem("user_token");
前端请求时,header 中统一添加 token, 可以写在common.js中 :
// header中统⼀添加token
$(document).ajaxSend(function(e,xhr,opt) {
var token = localStorage.getItem("user_token");
xhr.setRequestHeader("user_token_header", token);
});
4.3 服务器拦截器示例
实现强制要求登陆
当用户访问前端页面时,如果用户当前尚未登陆, 就自动跳转到登陆页面.
我们可以采用拦截器来完成,token通常由前端放在header中,我们从header中获取token,并校验
token是否合法
import com.example.blog.constant.Constants;
import com.example.blog.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Slf4j
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取handler
String token = request.getHeader(Constants.REQUEST_HEADER_TOKEN);
log.info("从header中获取token:{}", token);
Claims claims = JwtUtils.parseToken(token);
if(claims == null) {
//校验失败
response.setStatus(401);
return false;
}
return true;
}
}
import com.example.blog.interceptor.LoginInterceptor;
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;
import java.util.Arrays;
import java.util.List;
//拦截器配置
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
private final List excludes = Arrays.asList(
"/**/*.html",
"/blog-editormd/**",
"/css/**",
"/js/**",
"/pic/**",
"/login",
"/user/login",
"/favicon.ico"
);
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(excludes);
}
}