前言

JWT主要用于用户登录鉴权,那么它和之前的Session认证有什么区别呢?

  • 在JWT之前有Session认证,但Session存在于服务器中,由于HTTP协议是无状态协议,每次请求都要携带用户唯一标识,例如SessionId存在Cookie中,每次请求都放到请求头中带给服务端进行校验
  • Session的缺点很明显,因为它是存在应用服务器中的,如果服务器是分布式应用时,Session不能共享,很难拓展,如果将Session进行同步又要引入Redis
  • 引入Redis的好处是,分布式应用能很方便的从Redis获取SessionId,Redis也有提供分布式同步数据,但每次请求都要向Redis发出查询请求,也要给Redis增加压力,增加了请求的耗时,每个登录的用户的SessionId都要消耗Redis的存储空间,依旧不是很好的方式

JWT是什么?

  • JWT,全称为Json Web Token,是一个开放标准(RFC 7519),简单来说就是一种认证机制。
  • JWT分为3个部分,JWT头部、载荷、签名,每个部分使用.分开,xxxx.yyyy.zzz

JWT 头部

  • 头部,它是JWT的第一部分,用于描述JWT元数据的JSON对象
  • alg 属性,表示签名使用的算法,默认为 HMAC SHA256(写为HS256)
  • typ 属性,表示令牌的类型,JWT 令牌统一写为JWT
{
    "alg": "HS256",
    "typ": "JWT"
}
  • 最后,使用 Base64 编码算法,将上面的JSON对象转换为字符串

Payload 载荷

  • 载荷,它是JWT的第二部分,保存需要传递的数据,例如我们可以保存用户Id、用户昵称、用户的个性签名等,JWT也提供了7个默认字段
  • iss (issuer):签发人/发行人
  • sub (subject):主题
  • aud (audience):用户
  • exp (expiration time):过期时间
  • nbf (Not Before):生效时间,在此之前是无效的
  • iat (Issued At):签发时间
  • jti (JWT ID):用于标识该 JWT

示例:

{
    //默认字段
    "sub":"user",
    //自定义字段
    "userId":"10086",
    "nickname":"Barry",
    "sign":"I am Flash",
}
  • 注意:默认JWT是不加密的,3段JSON对象使用的是Base64编码,但Base64可以解码,所以不要在JWT中存放用户的敏感信息,例如用户账号的密码之类的

Signature 签名

  • 签名,它是JWT的最后一部分,生成规则:
  • 自定义一个加密盐secret,存放在服务器中,一定要保密,不能被其他人知道
  • 将上面的头部、载荷部分的JSON对象进行Base64编码,再使用.号进行连接,然后使用头部中声明的加密算法,加上加密盐进行加密,得出签名部分,这个签名无法反向解密,确保了安全性
  • 最终得出:header(Base64加密).payload(Base64加密).signature
  • 那么服务端校验Token,则是先使用.号分割为3部分,然后使用JWT的前2段,Base64编码后,得到头部header和载荷playload,就能得到头部的加密算法,配合服务器上的加密盐secret,重新加密一次第三部分签名,接着和JWT的第三部分做一次比对,如果相同,就是校验通过

JWT的优点

  • JWT使用JSON格式,所以可以跨语言
  • JWT第二部分playload,可以存储一些非敏感的用户数据,服务端接收到请求后,可以直接使用这部分数据,而不需要查询MySQL或Redis
  • JWT的结构简单,占用小,方便传输
  • 不需要在服务端存储会话信息,减少服务器的内存,分布式应用非常适合使用

JWT的缺点

  • 安全性无法保证,由于JWT的playload载荷部分只是使用Base64编码,所以不能存储敏感信息
  • JWT颁发的token,无法中途废弃,由于token在过期之前都是能够使用的,所以如果用户信息发生改变,但旧的token中的用户信息还是旧的,而且旧的token还没到过期时间的话,就一直是有效的
  • 要解决这个问题,也可以使用Redis存储token,然后映射另外一段唯一ID,客户端保存的是这段唯一ID,如果要中途废弃Token,在Redis中删除这个唯一ID,就可以让客户端只能重新登录,但引入Redis,有让无状态转变为了有状态

环境说明

  • 前端
  • axios
  • toastr
  • NProgress
  • ES6
  • 后端
  • SpringBoot 2.3.6
  • JWT
  • Java8

项目地址

业务分析

  • 前端,注册成功后,发起登录,后端校验用户名、密码等,校验成功,生成token,以及返回用户信息
  • 前端接收到后端返回的数据,保存token到本地存储中,后续在需要登录校验的请求都在请求头中带上token。一般在axios的请求拦截器中,统一设置token到请求头
  • 后端接收到前端请求后,判断token是否存在、token是否合法、token是否过期,校验通过则进行接口的业务操作,操作完成后返回业务数据,校验不通过则返回401状态码。一般在SpringMVC的拦截器中,统一进行token校验,校验不通过时抛出自定义异常,然后在全局异常拦截器中,返回统一的JSON结构数据
  • 对于401状态码的处理,前端一般在axios的响应拦截器中,统一对401状态码进行判断,如果后端返回401,则删除本地存储中的token,Toast提示用户需要重新登录,并跳转到登录页面
  • 对于一些明确是私有页面,也就是需要用户登录才能进入的页面,一般前端会直接判断本地存储是否有token,没有则直接跳转到登录页面,强制用户登录才能操作

效果图

前端

基础设施

axios配置

  • 配置axios的基地址
  • 设置请求、响应拦截器
  • 请求拦截器
  • 统一在请求头中添加token
  • 显示和隐藏进度条
  • 响应拦截器
  • 统一对数据进行剥离,拆解一层data,后续在使用请求结果时,可以减少一层
  • 统一处理401状态码,就是token过期后的处理,例如清除本地存储的token、Toast提示、跳转登录页等
  • 显示、隐藏进度条
// axios基地址
axios.defaults.baseURL = 'http://localhost:8080';

// 添加请求拦截器
axios.interceptors.request.use(function (config) {
    // 显示进度条
    NProgress.start();
    // 添加token到请求头
    const token = localStorage.getItem('token');
    if (token) {
        config.headers.Authorization = token;
    }
    return config;
}, function (error) {
    // 关闭进度条
    NProgress.done();
    // 对请求错误做些什么
    return Promise.reject(error);
});

// 添加响应拦截器
axios.interceptors.response.use(function (response) {
    // 2xx 范围内的状态码都会触发该函数。
    // 关闭进度条
    NProgress.done();
    // 数据剥离,剥离一层data,再继续传递,就不需要每次都拆多一层data了
    return response.data;
}, function (error) {
    // 关闭进度条
    NProgress.done();
    // 超出 2xx 范围的状态码都会触发该函数。
    // 判断HTTP状态码,如果为401,则是token过期
    const statusCode = error.response.status;
    if (statusCode === 401) {
        // 删除本地token
        localStorage.removeItem("cms90");
        // 提示用户
        toastr.error(error.response.data.message);
        // 延迟一会,跳转到首页
        setTimeout(() => {
            location.href = 'login.html';
        }, 1500);
    }
    return Promise.reject(error);
});

提供检查是否登录的函数

  • 本质是查询localStorage本地存储,是否有token存在,不存在则证明没有登录
// 检查是否登录,未登录则跳转到登录页面
function checkLogin() {
    const token = localStorage.getItem('token');
    if (!token) {
        toastr.error('请先登录');
        setTimeout(() => {
            location.href = 'login.html';
        }, 1000);
    }
}

注册、登录业务

注册

  • 页面有账号、密码输入框、注册按钮、已有账号,去登录按钮
注册页面(register.html)
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>注册</title>
    <link rel="stylesheet" href="./lib/toastr/toastr.min.css" />
    <link rel="stylesheet" href="./lib/NProgress/NProgress.css" />
    <link rel="stylesheet" href="./css/register.css">
</head>

<body>
    <form id="register-form">
        <h1>注册</h1>
        <input class="username" type="text" name="username" placeholder="请输入用户名"><br>
        <input class="password" type="password" name="password" placeholder="请输入密码"><br>
        <button class="register-btn" type="submit">注册</button><br>
        <a class="go-login-a" href="./login.html">已有账号,去登录</a>
    </form>
    <script src="./lib/jquery-3.6.0.min.js"></script>
    <!-- Toast提示 -->
    <script src="./lib/toastr/toastr.min.js"></script>
    <script src="./lib/axios.js"></script>
    <script src="./lib/form-serialize.js"></script>
    <!-- 引入 NProgress js -->
    <script src="./lib/NProgress/NProgress.js"></script>
    <!-- 引入公共的js -->
    <script src="./js/common.js"></script>
    <script src="./js/register.js"></script>
</body>

</html>
CSS(register.less)
#register-form {
    text-align: center;
    margin: 250px auto;
    .username {
        margin-bottom: 10px;
    }

    .password {
        margin: 5px;
    }

    .register-btn {
        margin-top: 5px;
    }

    .go-login-a {
        font-size: 12px;
    }
}
JS(register.js)
  • 监听注册表单提交事件
  • 使用serialize插件,收集表单数据
  • 正则表达式校验,表单数据
  • 使用axios发送注册请求
  • 注册成功
  • Toast提示用户注册成功
  • 跳转到登录页面
  • 注册失败
  • Toast提示用户,具体失败原因
// 注册表单
const registerForm = document.querySelector('#register-form');

// 监听表单的提交事件
registerForm.addEventListener('submit', async function (e) {
    // 阻止默认行为
    e.preventDefault();

    // 校验表单
    const data = serialize(registerForm, {
        hash: true,
        empty: true
    });

    const { username, password } = data;

    // 用户名正则表达式
    const usernameReg = /[a-zA-Z0-9]{3,15}/;
    // 密码的正则表达式
    const passwordReg = /[a-zA-Z0-9]{6,15}/;

    if (!usernameReg.test(username)) {
        toastr.error('用户名不合法,必须是3~15位的英文字母和数字组成');
        return;
    }
    if (!passwordReg.test(password)) {
        toastr.error('密码不合法,必须是6~15位的英文字母和数字组成');
        return;
    }

    // 发送注册请求
    const result = await axios({
        url: `/user/register`,
        method: 'POST',
        data: {
            username: username,
            password: password
        }
    });
    // 注册失败,显示错误信息
    if (result.code !== 0) {
        toastr.error(result.message);
    } else {
        toastr.success('注册成功');
        // 注册成功,延时跳转到登录页面
        setTimeout(() => {
            location.href = 'login.html';
        }, 1500);
    }
});

登录

  • 页面有账号、密码输入框、登录按钮、没有账号,去注册按钮
登录界面(login.html)
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>登录</title>
    <link rel="stylesheet" href="./lib/toastr/toastr.min.css" />
    <link rel="stylesheet" href="./lib/NProgress/NProgress.css" />
    <link rel="stylesheet" href="./css/login.css">
</head>

<body>
    <form id="login-form">
        <h1>登录</h1>
        <input class="username" type="text" name="username" placeholder="请输入用户名"><br>
        <input class="password" type="password" name="password" placeholder="请输入密码"><br>
        <button class="login-btn" type="submit">登录</button><br>
        <a class="go-register-a" href="./register.html">没有账号,去注册</a>
    </form>
    <script src="./lib/jquery-3.6.0.min.js"></script>
    <!-- Toast提示 -->
    <script src="./lib/toastr/toastr.min.js"></script>
    <script src="./lib/axios.js"></script>
    <script src="./lib/form-serialize.js"></script>
    <!-- 引入 NProgress js -->
    <script src="./lib/NProgress/NProgress.js"></script>
    <!-- 引入公共的js -->
    <script src="./js/common.js"></script>
    <script src="./js/login.js"></script>
</body>

</html>
CSS(login.less)
#login-form {
    text-align: center;
    margin: 250px auto;
    .username {
        margin-bottom: 10px;
    }

    .password {
        margin: 5px;
    }

    .login-btn {
        margin-top: 5px;
    }

    .go-register-a {
        font-size: 12px;
    }
}
JS(login.js)
  • 监听登录表单提交事件
  • 使用serialize插件,收集表单数据
  • 正则表达式校验,表单数据
  • 使用axios发送登录请求
  • 登录成功
  • Toast提示用户
  • 保存token、用户信息到本地存储localStorage
  • 跳转到登录页面
  • 登录失败
  • Toast提示用户,具体失败原因
// 登录表单
const loginForm = document.querySelector('#login-form');

// 监听表单的提交事件
loginForm.addEventListener('submit', async function (e) {
    // 阻止默认行为
    e.preventDefault();

    // 校验表单
    const data = serialize(loginForm, {
        hash: true,
        empty: true
    });

    const { username, password } = data;

    // 用户名正则表达式
    const usernameReg = /[a-zA-Z0-9]{3,15}/;
    // 密码的正则表达式
    const passwordReg = /[a-zA-Z0-9]{6,15}/;

    if (!usernameReg.test(username)) {
        toastr.error('用户名不合法,必须是3~15位的英文字母和数字组成');
        return;
    }
    if (!passwordReg.test(password)) {
        toastr.error('密码不合法,必须是6~15位的英文字母和数字组成');
        return;
    }

    // 发送登录请求
    const result = await axios({
        url: `/user/login`,
        method: 'POST',
        data: {
            username: username,
            password: password
        }
    });
    // 登录失败,显示错误信息
    if (result.code !== 0) {
        toastr.error(result.message);
    } else {
        toastr.success('登录成功');
        // 登录成功,保存Token和用户名到本地
        localStorage.setItem('token', result.data.token);
        localStorage.setItem('username', result.data.username);
        // 延时跳转到首页
        setTimeout(() => {
            location.href = 'index.html';
        }, 1500);
    }
});

首页

  • 页面有欢迎语、用户名、退出登录按钮
首页界面(index.html)
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>首页</title>
    <link rel="stylesheet" href="./lib/toastr/toastr.min.css" />
    <link rel="stylesheet" href="./lib/NProgress/NProgress.css" />
    <link rel="stylesheet" href="./css/index.css">
</head>

<body>
    <div class="container">
        <h1>欢迎你 <span class="username"></span></h1>
        <button class="logout-btn">退出登录</button>
    </div>
    <script src="./lib/jquery-3.6.0.min.js"></script>
    <!-- Toast提示 -->
    <script src="./lib/toastr/toastr.min.js"></script>
    <script src="./lib/axios.js"></script>
    <script src="./lib/form-serialize.js"></script>
    <!-- 引入 NProgress js -->
    <script src="./lib/NProgress/NProgress.js"></script>
    <!-- 引入公共的js -->
    <script src="./js/common.js"></script>
    <script src="./js/index.js"></script>
</body>

</html>
CSS(index.less)
.container {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.username {
    color: blue;
}

.logout-btn {
    width: 100px;
    height: 50px;
}
JS(index.js)
  • 访问私有页面,检查本地存储有token,没有则Toast提示用户,并直接跳转去登录页面
  • 发送请求,获取用户信息,并渲染页面
  • 点击退出登录按钮,清除本地存储的token、用户信息,Toast提示退出成功,跳转到登录页面
const username = document.querySelector('.username');
const logoutBtn = document.querySelector('.logout-btn');

// 检查是否登录,未登录跳转到登录页面
checkLogin();

// 获取用户信息
async function getUserInfo() {
    const result = await axios({
        url: `/user/getUserInfo`,
        method: 'GET'
    });
    // 显示用户名
    username.innerText = result.data.username;
}

// 退出登录
logoutBtn.addEventListener('click', function (e) {
    if (confirm('确认退出登录吗?')) {
        // 删除本地存储的Token和用户信息
        localStorage.removeItem('token');
        localStorage.removeItem('username');
        // 提示用户
        toastr.success('退出成功');
        // 跳转到登录页面
        setTimeout(() => {
            location.href = 'login.html';
        }, 1000);
    }
});

getUserInfo();

后端

基础设施

添加依赖

<!-- jwt依赖 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
</dependency>
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
</dependency>

JWT密钥

  • 定义JWT使用的密钥,也就是JWT第三部分处,使用第一部分头部的加密算法和这个加密盐,加密JWT的第一、二部分(已Base64编码后),得到第三部分
  • 加密盐,一般会单独以一个文件存放在服务器的某个目录,再通过配置中心,配置其地址,这里简单起见,直接写死在Java代码中
public class ServiceConstant {
    /**
     * 服务器加密盐,token加密使用
     */
    public static final String SERVER_SECRET = "XX#$%()(#*!(){LWPW\"";
}

JWT工具类

  • JWT工具类,定义了以下方法
  • 创建token:createToken
  • 校验token:verifierToken
  • 解析token:parseToken
  • 解析token,并获取载荷中保存的用户Id:getUserIdByToken
  • 以及token的过期时间,一般这个时间会配置到配置中心,例如nacos,这里简单起见,直接写死在Java代码中
public class JwtUtil {
    private static final String EXP = "exp";
    private static final String PAYLOAD = "payload";
    /**
     * 过期时间,一个月
     */
    private static final long EXPIRED_TIME = TimeUnit.DAYS.toMillis(30);

    private static final String KEY_USER_ID = "userId";

    private JwtUtil() {
    }

    /**
     * 生成Token
     *
     * @param userId 用户Id
     */
    public static String createToken(Long userId) {
        Map info = new HashMap<>();
        info.put(KEY_USER_ID, userId);
        return createToken(info, EXPIRED_TIME, ServiceConstant.SERVER_SECRET);
    }

    /**
     * 加密生成token
     *
     * @param object 载体信息
     * @param maxAge 有效时长
     * @param secret 服务器私钥
     * @return Token令牌
     */
    private static <T> String createToken(T object, long maxAge, String secret) {
        try {
            final JWTSigner signer = new JWTSigner(secret);
            final Map<String, Object> claims = new HashMap<>();
            ObjectMapper mapper = new ObjectMapper();
            String jsonString = mapper.writeValueAsString(object);
            claims.put(PAYLOAD, jsonString);
            claims.put(EXP, System.currentTimeMillis() + maxAge);
            return signer.sign(claims);
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * 验证Token是否有效
     *
     * @param token 令牌
     */
    public static boolean verifierToken(String token) {
        Long userId = getUserIdByToken(token);
        return userId != null && userId > 0;
    }

    /**
     * 解析token
     *
     * @param token  加密后的token字符串
     * @param clazz  解密后的对象类型
     * @param secret 服务器私钥
     * @return 如果token验证成功返回classT类型的对象,如果验证失败或过期返回null
     */
    private static <T> T parseToken(String token, Class<T> clazz, String secret) {
        final JWTVerifier verifier = new JWTVerifier(secret);
        try {
            final Map<String, Object> claims = verifier.verify(token);
            if (claims.containsKey(EXP) && claims.containsKey(PAYLOAD)) {
                long exp = (Long) claims.get(EXP);
                long currentTimeMillis = System.currentTimeMillis();
                //没有过期
                if (currentTimeMillis < exp) {
                    String json = (String) claims.get(PAYLOAD);
                    ObjectMapper objectMapper = new ObjectMapper();
                    return objectMapper.readValue(json, clazz);
                }
            }
            return null;
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * 从Token中获取UserId
     *
     * @param token 令牌
     */
    public static Long getUserIdByToken(String token) {
        Map map = parseToken(token, Map.class, ServiceConstant.SERVER_SECRET);
        if (map == null) {
            return null;
        }
        return Long.parseLong((map.get(KEY_USER_ID)).toString());
    }
}

自定义Token校验注解

  • 定义一个自定义注解,用于配置在Controller的接口方法中,需要校验token的接口方法,会在SpringMVC的拦截器中,校验token成功再调用,如果校验不通过则直接拦截请求,返回401
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface VerifyToken {
    /**
     * 是否必须校验,默认为true为校验,如果设置为false,则不校验
     */
    boolean required() default true;
}

自定义异常枚举类

  • 定义自定义异常类,定义了很多业务异常code和错误信息,例如:
  • 在Service层校验数据,发现有业务问题,直接抛出异常,统一在全局异常处理器中,返回统一的JSON格式数据
  • SpringMVC的拦截器中,校验token成功再调用,如果校验不通过,抛出异常,在统一在全局异常处理器中返回401HTTP状态码
public enum BizEnum {
    USER_ID_NOT_NULL(416, "用户Id不能为空"),
    DB_ERROR(999, "数据库错误"),
    SYSTEM_ERROR(100000, "系统异常"),
    TOKEN_NO_EXIST(1000, "Token令牌必传"),
    TOKEN_INVALID(1001, "Token令牌非法"),
    USER_NOT_EXIST(1002, "用户不存在"),
    REGISTER_USER_IS_EXIST(1004, "注册失败,该用户已存在");

    private final int code;
    private final String message;

    BizEnum(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}

自定义Token校验异常

  • 自定义Token校验异常,在SpringMVC的拦截器中,如果校验不通过,抛出该异常
@Data
@EqualsAndHashCode(callSuper = true)
public class UnauthorizedException extends RuntimeException {
    private BizEnum bizEnum;

    public UnauthorizedException(BizEnum bizEnum) {
        super(bizEnum.getMessage());
        this.bizEnum = bizEnum;
    }
}

全局异常拦截器

  • 处理UnauthorizedException自定义Token校验异常,返回401HTTP状态码,以及对应的错误信息
  • 处理UnauthorizedException,返回服务器异常的错误信息
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    /**
     * Token异常处理
     */
    @ExceptionHandler(UnauthorizedException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public ApiResult<String> unauthorizedErrorHandler(UnauthorizedException e) {
        log.error(e.getMessage());
        return ApiResult.fail(e.getBizEnum());
    }

    /**
     * 默认异常处理
     */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiResult<String> defaultErrorHandler(Exception e) {
        log.error(e.getMessage());
        return ApiResult.fail(BizEnum.SYSTEM_ERROR);
    }
}

SpringMVC拦截器

  • 一个自定义的SpringMVC拦截器,统一拦截Controller层方法,反射读取VerifyToken注解,如果是需要校验的接口方法,则读取请求头的token,并校验token的合法性,如校验通过则放行该接口方法,否则抛出UnauthorizedException异常,统一在全局异常处理器中,返回401HTTP状态码给前端
public class AuthInterceptor extends HandlerInterceptorAdapter {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //获取header上的token
        String token = request.getHeader("Authorization");
        //如果不是Controller上的方法直接放行
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        //获取Controller上的方法,查看是否标识了自定义注解
        if (method.isAnnotationPresent(VerifyToken.class)) {
            VerifyToken annotation = method.getAnnotation(VerifyToken.class);
            //设置了需要校验,则进行校验,否则不校验,直接放行
            if (annotation.required()) {
                //Token没传
                if (StringUtils.isBlank(token)) {
                    throw new UnauthorizedException(BizEnum.TOKEN_NO_EXIST);
                }
                //校验Token
                boolean isValid = JwtUtil.verifierToken(token);
                if (!isValid) {
                    throw new UnauthorizedException(BizEnum.TOKEN_INVALID);
                }
                //获取Token中的信息
                Long userId;
                try {
                    userId = JwtUtil.getUserIdByToken(token);
                } catch (Exception e) {
                    throw new UnauthorizedException(BizEnum.TOKEN_INVALID);
                }
                //获取Token中的UserId,获取不到,则也无效
                if (userId == null) {
                    throw new UnauthorizedException(BizEnum.TOKEN_INVALID);
                }
                //保存用户Id,方便在后续的Service层中获取用户Id
                UserHolder.setUserId(userId);
                return true;
            }
        }
        return true;
    }
}

SpringMVC配置类

  • 添加Token验证拦截器,并设置拦截所有请求
@Configuration
public class InterceptorConfig extends WebMvcConfigurerAdapter {
    @Bean
    public AuthInterceptor authInterceptor() {
        return new AuthInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry
                //添加Token验证拦截器
                .addInterceptor(authInterceptor())
                //拦截所有请求
                .addPathPatterns("/**");
    }
}

跨域配置

@Configuration
public class CrosConfig extends WebMvcConfigurerAdapter {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry
                //设置允许跨域的路径
                .addMapping("/**")
                //设置允许跨域请求的域名
                .allowedOriginPatterns("*")
                //是否允许证书
                .allowCredentials(true)
                //设置允许的请求方式
                .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
                //设置允许的header属性
                .allowedHeaders("*")
                //允许跨域时间
                .maxAge(3600);
    }
}

注册、登录业务

启动类

  • 配置MyBatis扫描的包路径
@SpringBootApplication
//指定MyBatis扫描的包路径
@MapperScan("com.zh.jwt.mapper")
public class AppApplication {
    public static void main(String[] args) {
        SpringApplication.run(AppApplication.class, args);
    }
}

application.yml配置文件

  • 服务端端口:8080
  • 服务名称:user-server
  • 配置MySQL连接地址和密码、连接池等信息
  • Log打印配置
  • MyBatisPlus配置
server:
  port: 8080

spring:
  application:
    name: user-server
  mvc:
    #处理404问题,出现错误时, 直接抛出异常,统一捕获异常进行统一的json返回
    throw-exception-if-no-handler-found: true
  resources:
    #不要为工程中的资源文件建立映射,不配置的话,404处理不生效
    add-mappings: false
  #配置数据库
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/springboot_jwt?useUnicode=true&characterEncoding=UTF-8&useSSL=false
    username: root
    password: root
    # druid连接池
    type: com.alibaba.druid.pool.DruidDataSource
    # 指定数据库类型 mysql
    dbType: mysql
    # 启动初始化连接数量
    initialSize: 5
    # 最小空闲连接
    minIdle: 5
    # 最大连接数量(包含使用中的和空闲的)
    maxActive: 20
    # 最大连接等待时间 ,超出时间报错
    maxWait: 60000
    # 设置执行一次连接回收器的时间
    timeBetweenEvictionRunsMillis: 60000
    # 设置时间: 该时间内没有任何操作的空闲连接会被回收
    minEvictableIdleTimeMillis: 300000
    # 验证连接有效性的sql
    validationQuery: select 'x'
    # 空闲时校验
    testWhileIdle: true
    # 使用中是否校验有效性
    testOnBorrow: false
    # 归还连接池时是否校验
    testOnReturn: false
    # mysql 不推荐打开预处理连接池
    poolPreparedStatements: false
    #设置过滤器 stat用于接收状态,wall防止sql注入,logback说明使用logback进行日志输出
    filters: stat,wall,logback
    # 统计所有数据源状态
    userGlobalataSourceStat: true

#配置Log打印
logging:
  level:
    root: warn
    com.zh.jwt: trace
  pattern:
    console: '%p%m%n'

mybatis-plus:
  #配置mapper文件的位置,注意,如果你是maven多模块下使用,路径前需要加classpath*,即加载多个jar包下的xml文件
  #xml的包路径,如果和java的一样,千万通配符后面要加后缀,否则启动时,自动注入会报错,MalformedByteSequenceException: 2 字节的 UTF-8 序列的字节 2 无效
  mapper-locations:
    - classpath*:com/zh/jwt/mapper/*.xml
  global-config:
    db-config:
      #全局id策略
      id-type: auto
      #字段生成sql的where策略,默认实体中的字段为null时,不添加到sql中,如果想为null,也添加到sql语句中,则使用ignored
      #一般不设置为ignored,因为在update语句中,如果不设置值,就会用null覆盖掉原有的值。一般我们是希望设置值的才更新,为null则不更新
      #not_empty,如果字段值为null或者空字符串,会忽略掉,不添加到sql语句中
      field-strategy: default
      #统一表名前缀
      #table-prefix: mp_
      #数据库表是否使用下划线间隔命名,默认为true
      table-underline: true
      #逻辑删除,未删除的字段值,0
      logic-not-delete-value: 0
      #逻辑删除,已删除的字段值,1
      logic-delete-value: 1
  #实体别名包配置
  type-aliases-package: com.zh.jwt.model
  #注意configuration不能和config-location同时出现,不然会报错
  configuration:
    #驼峰转下划线(实体类用驼峰,数据库表字段用下划线),默认为true
    map-underscore-to-camel-case: true

数据库表SQL

  • 库名:springboot_jwt
  • 表名:tb_user
/*
SQLyog Enterprise v12.14 (64 bit)
MySQL - 5.7.17-log : Database - springboot_jwt
*********************************************************************
*/


/*!40101 SET NAMES utf8 */;

/*!40101 SET SQL_MODE=''*/;

/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE DATABASE /*!32312 IF NOT EXISTS*/`springboot_jwt` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;

USE `springboot_jwt`;

/*Table structure for table `tb_user` */

DROP TABLE IF EXISTS `tb_user`;

CREATE TABLE `tb_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `username` varchar(15) DEFAULT NULL COMMENT '用户名',
  `password` varchar(32) DEFAULT NULL COMMENT '密码',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1684066110497906693 DEFAULT CHARSET=utf8mb4;

/*Data for the table `tb_user` */

insert  into `tb_user`(`id`,`username`,`password`,`create_time`,`update_time`) values 

(1,'barry','e10adc3949ba59abbe56e057f20f883e','2023-07-25 00:00:00','2023-07-25 00:00:00'),

(1683872660632682497,'wally','e10adc3949ba59abbe56e057f20f883e','2023-07-26 16:27:24','2023-07-26 16:27:28'),

(1684039328491175938,'hezihao','e10adc3949ba59abbe56e057f20f883e','2023-07-26 16:27:37','2023-07-26 16:27:38'),

(1684066110497906689,'hezihao2','e10adc3949ba59abbe56e057f20f883e','2023-07-26 16:27:40','2023-07-26 16:27:42');

/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

实体类

用户表实体类
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("tb_user")
public class TbUser implements Serializable {
    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 用户名
     */
    @TableField(value = "username")
    private String username;
    /**
     * 密码
     */
    private String password;

    /**
     * 创建时间
     */
    @TableField(value = "create_time")
    private Date createTime;
    /**
     * 更新时间
     */
    @TableField(value = "update_time")
    private Date updateTime;
}
用户表DTO
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class UserDTO {
    /**
     * 用户Id
     */
    private Long id;
    /**
     * 用户名
     */
    private String username;
    /**
     * 密码
     */
    private String password;
}
用户表VO
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class UserVO implements Serializable {
    private static final long serialVersionUID = 1L;

    /**
     * 用户Id
     */
    private Long id;

    /**
     * 用户名
     */
    private String username;
}
登录结果VO
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class LoginResultVO implements Serializable {
    private static final long serialVersionUID = 1L;

    /**
     * 用户Id
     */
    private Long id;

    /**
     * 令牌
     */
    private String token;

    /**
     * 用户名
     */
    private String username;
}

用户控制器

  • 提供3个接口方法
  • 登录:login
  • 注册:register
  • 获取用户信息:getUserInfo
@RequestMapping("/user")
@RestController
public class UserController {
    @Autowired
    private UserService userService;

    /**
     * 登录
     */
    @PostMapping("/login")
    public ApiResult<LoginResultVO> login(@RequestBody UserDTO dto) {
        return userService.login(dto);
    }

    /**
     * 注册
     */
    @PostMapping("/register")
    public ApiResult<Void> register(@RequestBody UserDTO dto) {
        return userService.register(dto);
    }

    /**
     * 获取用户信息
     */
    @VerifyToken
    @GetMapping("/getUserInfo")
    public ApiResult<UserVO> getUserInfo() {
        return userService.getUserInfo();
    }
}

用户业务层

用户业务层接口
public interface UserService extends IService<TbUser> {
    /**
     * 获取用户信息
     */
    ApiResult<UserVO> getUserInfo();

    /**
     * 登录
     */
    ApiResult<LoginResultVO> login(UserDTO dto);

    /**
     * 注册
     */
    ApiResult<Void> register(UserDTO dto);
}
用户业务层实现
  • 登录:
  • 用户名是否存在
  • 校验用户名和密码是否一致
  • 校验通过,则生成Token,以及返回用户信息
  • 校验不通过,返回异常信息
  • 注册
  • 校验用户名是否已被占用
  • 未占用,保存用户名和密码,插入到数据库表中
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, TbUser> implements UserService {
    @Override
    public ApiResult<UserVO> getUserInfo() {
        Long userId = UserHolder.getUserId();
        //使用用户Id,查询用户信息
        TbUser tbUser = baseMapper.selectById(userId);
        if (tbUser == null) {
            return ApiResult.fail("无数据");
        }
        //属性拷贝
        UserVO vo = new UserVO();
        BeanUtils.copyProperties(tbUser, vo);
        return ApiResult.success(vo);
    }

    @Override
    public ApiResult<LoginResultVO> login(UserDTO dto) {
        String username = dto.getUsername();
        String password = dto.getPassword();
        if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) {
            return ApiResult.fail("登录失败,账号或密码不能为空");
        }
        LambdaQueryWrapper<TbUser> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(TbUser::getUsername, username);
        TbUser tbUser = baseMapper.selectOne(queryWrapper);
        //用户不存在
        if (tbUser == null) {
            return ApiResult.fail(BizEnum.USER_NOT_EXIST);
        }
        //密码MD5加密
        String pwd = DigestUtils.md5DigestAsHex(password.getBytes());
        //校验密码
        if (tbUser.getPassword().equals(pwd)) {
            LoginResultVO vo = new LoginResultVO();
            BeanUtils.copyProperties(tbUser, vo);
            //生成Token
            String token = JwtUtil.createToken(vo.getId());
            vo.setToken(token);
            return ApiResult.success(vo);
        }
        return ApiResult.fail("登录失败,请检查用户名和密码是否正确");
    }

    @Override
    public ApiResult<Void> register(UserDTO dto) {
        String username = dto.getUsername();
        String password = dto.getPassword();
        if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) {
            return ApiResult.fail("注册信息不完整,用户名和密码不能为空");
        }
        //校验用户是否已经注册了
        LambdaQueryWrapper<TbUser> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(TbUser::getUsername, username);
        TbUser tbUser = baseMapper.selectOne(queryWrapper);
        if (tbUser != null) {
            throw new BizException(BizEnum.REGISTER_USER_IS_EXIST);
        }
        //密码MD5加密
        String pwd = DigestUtils.md5DigestAsHex(password.getBytes());
        //增加一个用户
        TbUser user = new TbUser();
        user.setUsername(username);
        user.setPassword(pwd);
        //创建时间
        user.setCreateTime(new Date());
        //更新时间
        user.setUpdateTime(new Date());
        baseMapper.insert(user);
        return ApiResult.success("注册成功");
    }
}

用户表Mapper

  • 暂时没有自定义方法,都是使用MyBatisPlus提供的单表查询方法
public interface UserMapper extends BaseMapper<TbUser> {
}