SpringBoot + Vue 集成 JWT 实现 Token 验证以及一种 Token 刷新策略的实现

导读:本篇文章讲解 SpringBoot + Vue 集成 JWT 实现 Token 验证以及一种 Token 刷新策略的实现,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

前言

本文将介绍基于 SpringBootVue 的前后端分离项目集成 JWT 的一种思路,此外还包括在包括记住密码功能情况下 Token 的刷新策略,本文假设你对以下知识有一定的了解,如果未接触过,建议先看一下推荐链接的内容:

下面这张图是本文实现 Token 验证以及刷新 Token 的基本思路,本文展示了实现最终效果的核心代码,完整代码(包括前端和后端代码)已同步到GitHub

image-20210131213332799

效果

下面是最终的效果展示,只展示了前端的界面部分:

1

image-20210131222355105

image-20210131222445065

实现

由于完整代码已上传到GitHub,本文将只展示了核心相关代码的实现思路,首先是后端部分:

后端实现

这里先简要介绍一下 Token 的刷新策略,为了实现在记住密码时 Token 失效时能得到刷新,这里将用户的最近一次登陆时间存储在 redis 中,当然也可以存在数据库中,如果满足刷新条件,更新 redis 中的最近一次登陆时间以及 Token 有限期限并刷新 Token

下面再展示具体的配置,首先展示配置文件的设置:

spring:
  # 取消 banner 图标
  main:
    banner-mode: off

  # mysql 数据库连接设置
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/blog?serverTimezone=GMT%2B8&charset=utf8mb4&useSSL=false
    username: root
    password: root

  # jpa 相关设置
  jpa:
    show-sql: true
    properties:
      hibernate:
      dialect: org.hibernate.dialect.MySQL5InnoDBDialect
    open-in-view: false

  # redis 相关配置
  redis:
    database: 0
    host: localhost
    port: 6379
    lettuce:
      pool:
        max-active: 8
        max-wait: -1
        max-idle: 10
        min-idle: 5
      shutdown-timeout: 100ms

# 设置端口
server:
  port: 8888

# 设置 jwt, 涉及时间单位均为秒
jwt:
  # 设置 Token 过期时间
  expiration: 10
  # 设置 JWT 的密钥
  secret: zjw1221
  # 设置记住密码的时间
  remember-time: 1296000
  # 设置小于某个时间, 自动更新 Token
  validate-time: 300

首先是 WebMvc 的配置:

@Configuration
public class WebConfig extends WebMvcConfigurationSupport {

	private final HttpInterceptor httpInterceptor;

	@Autowired
	public WebConfig(HttpInterceptor httpInterceptor) {
		this.httpInterceptor = httpInterceptor;
	}

	@Override
	public void addCorsMappings(CorsRegistry registry) {
		// 跨域相关配置, 并让 authorization 可在响应头中出现
		registry.addMapping("/**")
				.allowedOrigins("*")
				.allowedMethods("POST", "GET", "PUT", "PATCH", "OPTIONS", "DELETE")
				.exposedHeaders("authorization")
				.allowedHeaders("*")
				.maxAge(3600);
	}

	@Override
	protected void addInterceptors(InterceptorRegistry registry) {
		// 设置自定义的拦截器, 拦截所有界面
		// 排除 /login 请求, 未防止 /login 失效,/error 也加入
		registry.addInterceptor(httpInterceptor)
				.addPathPatterns("/**")
				.excludePathPatterns("/login", "/error");
		super.addInterceptors(registry);
	}

}

然后是拦截器的设置:

@Configuration
public class HttpInterceptor implements HandlerInterceptor {

    private final Gson gson;
    private final UserSecurityUtil userSecurityUtil;

    @Autowired
    public HttpInterceptor(Gson gson, UserSecurityUtil userSecurityUtil) {
        this.gson = gson;
        this.userSecurityUtil = userSecurityUtil;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 为了处理跨域请求, 如果发送的是 OPTIONS 直接正常返回
        if (HttpMethod.OPTIONS.matches(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
            return true;
        }

        // 设置请求头和响应头的编码格式
        request.setCharacterEncoding("UTF-8");
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=utf-8");

        // 调用校验方法, 校验请求头的 Token 是否合法
        boolean isOk = userSecurityUtil.verifyWebToken(request, response);

        // 如果不合法, 返回 false, 否则为 true
        if (!isOk) {
            ResultEntity<String> resultEntity = new ResultEntity<>();
            resultEntity.setErrMsg("请重新登录");
            resultEntity.setStatus(false);
            response.getWriter().write(gson.toJson(resultEntity));
            return false;
        }
        return true;
    }

}

之后是校验方法的设置:

@Component
public class UserSecurityUtil {

    private final RedisUtil redis;
    @Value("${jwt.validate-time}")
    private long validateTime;

    @Autowired
    public UserSecurityUtil(RedisUtil redis) {
        this.redis = redis;
    }

    public boolean verifyWebToken(HttpServletRequest req, HttpServletResponse resp) {
        // 获取请求头中的 authorization 信息
        String token = req.getHeader("authorization");
        // 如果为空直接返回 false
        if (token == null) {
            return false;
        }
        // 解码 Token 信息, 如果为空直接返回 false
        DecodedJWT jwtToken = JwtUtil.decode(token);
        if (jwtToken == null) {
            return false;
        }
        // 获取 Token 信息中的用户 id 信息
        // 到 redis 中判断, 如果不存在相关信息直接返回 false
        long uid = Long.parseLong(jwtToken.getSubject());
        if (redis.getExpire(uid) == -2) {
            return false;
        }
        // 根据 uid 到 redis 中获取 JwtEntity 实体信息
        JwtEntity jwtEntity = (JwtEntity) redis.get(uid);
        try {
            // 继续校验
            JwtUtil.verifyToken(token);
        } catch (SignatureVerificationException e) {
            // 出现签名校验异常直接返回 false
            return false;
        } catch (TokenExpiredException e) {
            // 如果过期, 判断是否符合获得刷新 Token 的条件
            // 如果返回为空, 说明 Token 过期, 删除 redis 中的信息, 并返回 false
            String newToken = JwtUtil.getRefreshToken(jwtToken, jwtEntity);
            if (newToken == null) {
                redis.del(uid);
                return false;
            }
            // 否则说明符合 token 刷新条件, 设置返回头部的 authorization, 并返回 true
            resp.setHeader("authorization", newToken);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        // 校验成功, 判断是否为不记住密码, 且 Token剩余有效时间小于某个特定值
        // 若小于某个特定时间, 则刷新 Token
        if (!jwtEntity.getIsRemember()) {
            Instant exp = jwtEntity.getLastLoginTime().atZone(ZoneId.systemDefault()).toInstant();
            Instant now = Instant.now();
            if (now.getEpochSecond() - exp.getEpochSecond() <= validateTime) {
                token = JwtUtil.getRefreshToken(jwtToken);
            }
        }
        // 设置返回头中的 token
        resp.setHeader("authorization", token);
        return true;
    }
}

然后是 JwtEntity 类设置:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class JwtEntity {

    private String token;
    // 防止 LocalDateTime 在序列化中出错
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    private LocalDateTime lastLoginTime;
    private Boolean isRemember;

}

然后是 JwtUtil 的设置:

@Component
public class JwtUtil {

    private static String secret;
    private static long expiration;
    private static long rememberTime;
    private static RedisUtil redis;

    @Autowired
    public JwtUtil(RedisUtil redis) {
        JwtUtil.redis = redis;
    }

    @Value("${jwt.secret}")
    public void setSecret(String secret) {
        JwtUtil.secret = secret;
    }

    @Value("${jwt.expiration}")
    public void setExpiration(long expiration) {
        JwtUtil.expiration = expiration;
    }

    @Value("${jwt.remember-time}")
    public void setRememberTime(long rememberTime) {
        JwtUtil.rememberTime = rememberTime;
    }

    public static String createToken(Long uid, Instant issueAt) {
        // 生成 Token
        Instant exp = issueAt.plusSeconds(expiration);
        return createToken(uid.toString(), issueAt, exp);
    }

    public static DecodedJWT decode(String token){
        try {
            // 返回 Token 的解码信息
            return JWT.decode(token);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    public static void verifyToken(String token) {
        // 校验 Token
        JWTVerifier verifier = JWT.require(Algorithm.HMAC256(JwtUtil.secret)).build();
        verifier.verify(token);
    }

    public static String getRefreshToken(DecodedJWT jwtToken, JwtEntity jwtEntity) {
        // 这个刷新 Token 类用于处理 Token 失效的情况
        Instant exp = jwtEntity.getLastLoginTime().atZone(ZoneId.systemDefault()).toInstant();
        Instant now = Instant.now();
        // 如果超过记住密码的期限或者是设置未记住密码, 直接返回 null
        if (!jwtEntity.getIsRemember() || (now.getEpochSecond() - exp.getEpochSecond()) > rememberTime) {
            return null;
        }
        // 否则生成刷新的 Token, 并重新设置 redis 中存储的信息
        Instant newExp = exp.plusSeconds(expiration);
        String token = createToken(jwtToken.getSubject(), now, newExp);
        LocalDateTime lastLoginTime = getLastLoginTime(newExp);
        redis.set(jwtToken.getSubject(), new JwtEntity(token, lastLoginTime, true));
        return token;
    }

    public static String getRefreshToken(DecodedJWT jwtToken) {
        // 这个刷新 Token 类用于处理 Token 有效时间小于某个特定的值的情况
        // 生成刷新的 Token, 并重新设置 redis 中存储的信息
        Instant now = Instant.now();
        Instant newExp = now.plusSeconds(expiration);
        String token = createToken(jwtToken.getSubject(), now, newExp);
        redis.set(jwtToken.getSubject(), new JwtEntity(token, getLastLoginTime(now), false));
        return token;
    }

    private static String createToken(String sub, Instant iat, Instant exp) {
        // 生成 Token, 包括用户 uid, 生效和失效日期
        return JWT.create()
                .withClaim("sub", sub)
                .withClaim("iat", Date.from(iat))
                .withClaim("exp", Date.from(exp))
                .sign(Algorithm.HMAC256(JwtUtil.secret));
    }

    private static LocalDateTime getLastLoginTime(Instant newExp) {
        // 获取当前时间的 LocalDateTime 格式
        return LocalDateTime.ofInstant(newExp, ZoneId.systemDefault());
    }

}

然后就是登录控制器的设置:

@RestController
public class LoginInController {

    private final UserService userService;

    @Autowired
    public LoginInController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/login")
    public ResultEntity<Long> login(@RequestBody LoginEntity loginEntity, HttpServletResponse response) {
        ResultEntity<Long> resultEntity = new ResultEntity<>();
        // 对登录信息中的用户名和密码进行校验
        User user = userService.findByUsername(loginEntity.getUsername());
        boolean isOk = user != null && user.getPassword().equals(loginEntity.getPassword());
        // 如果不满足, 直接返回
        if (!isOk) {
            resultEntity.setErrMsg("用户名或密码错误");
            resultEntity.setStatus(false);
            return resultEntity;
        }
        // 否则生成 Token, 并设置头部的 authorization 信息
        String token = userService.createWebToken(user.getId(), loginEntity.getIsRemember());
        response.setHeader("authorization", token);
        resultEntity.setData(user.getId());
        return resultEntity;
    }

}

登录实体类如下:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginEntity {

    private String username;
    private String password;
    private Boolean isRemember;
}

UserService 内容如下:

@Service
public class UserService {

    private final UserDao dao;
    private final RedisUtil redis;

    @Autowired
    public UserService(UserDao dao, RedisUtil redis) {
        this.dao = dao;
        this.redis = redis;
    }

    public User findById(Long uid) {
        return dao.findById(uid).orElse(null);
    }

    public User findByUsername(String username) {
        return dao.findByUsername(username);
    }

    @Transactional
    public String createWebToken(Long uid, Boolean isRemember) {
        // 调用 JwtUtil 工具类的生成 Token 方法
        // 并在 redis 中存储对应用户的 token, 上次登录时间, 是否记住密码等时间
        Instant now = Instant.now();
        String token = JwtUtil.createToken(uid, now);
        LocalDateTime lastLoginTime = LocalDateTime.ofInstant(now, ZoneId.systemDefault());
        redis.set(uid, new JwtEntity(token, lastLoginTime, isRemember != null && isRemember));
        return token;
    }

    @Transactional
    public void deleteWebToken(Long uid) {
        // 从 redis 中删除对应用户的 Jwt 信息
        redis.del(uid);
    }

}

注销的控制器设置如下:

@RestController
public class LoginOutController {

    private final UserService userService;

    @Autowired
    public LoginOutController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/exit")
    public String login(Long uid) {
        // 用户注销时, 根据用户 id 删除 redis 中存储的信息
        // 由于前台 uid 存在 localStorage, 因此这里存在一些安全问题
        // 不过本文属于 Jwt 的入门使用, 不做进一步处理
        userService.deleteWebToken(uid);
        return "注销成功";
    }

}

前端实现

首先是登录界面:

<template>
  <el-row type="flex" class="login">
    <el-col :span="6">
      <h1 class="title">JWT 测试</h1>
      <el-form :model="loginForm" :rules="rules" status-icon ref="ruleForm" class="demo-ruleForm">
        <el-form-item prop="username">
          <el-input
            v-model="loginForm.username"
            autocomplete="off"
            placeholder="用户名"
            prefix-icon="el-icon-user-solid"
          ></el-input>
        </el-form-item>
        <el-form-item prop="password">
          <el-input
            type="password"
            v-model="loginForm.password"
            autocomplete="off"
            placeholder="请输入密码"
            prefix-icon="el-icon-lock"
          ></el-input>
        </el-form-item>
        <el-form-item prop="isRemember">
          <el-checkbox v-model="loginForm.isRemember" class="remember">记住密码</el-checkbox>
          <el-button type="primary" @click="submitForm" class="login-btn">登录</el-button>
        </el-form-item>
      </el-form>
    </el-col>
  </el-row>
</template>
<script>

import {
  Row,
  Col,
  Form,
  Input,
  Button,
  Message,
  Checkbox,
  FormItem,
  Loading
} from 'element-ui'
import {request} from '../network/request'

export default {
  name: 'Login',
  components: {
    'el-row': Row,
    'el-col': Col,
    'el-form': Form,
    'el-checkbox': Checkbox,
    'el-input': Input,
    'el-button': Button,
    'el-form-item': FormItem
  },
  data() {
    return {
      loginForm: {
        username: '',
        password: '',
        isRemember: false
      },
      rules: {
        username: [{
          required: true,
          message: '请输入用户名',
          trigger: 'blur'
        }],
        password: [{
          required: true,
          message: '请输入密码',
          trigger: 'blur'
        }]
      }
    }
  },
  methods: {
    submitForm() {
      const loading = Loading.service({ fullscreen: true })
      request({
        method: 'post',
        url: '/login',
        data: {
          'username': this.loginForm.username,
          'password': this.loginForm.password,
          'isRemember': this.loginForm.isRemember
        }
      }).then(res => {
        loading.close()
        // 登录成功将用户 uid 存入 localStorage
        localStorage.setItem('uid', res.data)
        // 并跳转到 home 界面
        this.$router.push('/home')
        Message('登录成功')
      }).catch(err => {
        console.log(err)
      })
    }
  }
}
</script>

然后是 Home 界面:

<template>
  <el-row type="flex" justify="center" align="middle" class="main">
    <el-col :span="12">
      <el-button @click="exit">注销</el-button>
      <el-button @click="test">测试</el-button>
    </el-col>
  </el-row>
</template>

<script>

import {
  Row,
  Col,
  Button,
  Message
} from 'element-ui'
import {request} from '../network/request'

export default {
  name: 'Home',
  components: {
    'el-row': Row,
    'el-col': Col,
    'el-button': Button
  },
  methods: {
    exit() {
      request({
        method: 'get',
        url: '/exit',
        params: {
          uid: localStorage.getItem('uid')
        }
      }).then(() => {
        // 用户注销后清除本地 Token
        localStorage.removeItem('authorization')
        // 并跳转到登录界面
        this.$router.push('/login')
        Message('注销成功')
      }).catch(err => {
        console.log(err)
      })
    },
    test() {
      request({
        method: 'get',
        url: '/hello',
      }).then(res => {
        if (res.data) {
          // Token 未过期则会正常返回 'Hello, world!' 信息
          Message(res.data)
        } else {
          // 否则提示用户登录
          Message('请重新登录')
        }
      }).catch(err => {
        console.log(err)
      })
    }
  }
}
</script>

路由配置如下:

import Vue from 'vue'
import VueRouter from 'vue-router'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    component() {
      return import('@/views/Home')
    }
  },
  {
    path: '/home',
    name: 'Home',
    component() {
      return import('@/views/Home')
    }
  },
  {
    path:'/login',
    name:'Login',
    component() {
      return import('@/views/Login')
    }
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})


// 设置全局的前置导航守卫
router.beforeEach((to, from, next) => {
  // 如果跳转的目的路径是 login 界面, 不做操作
  if (to.path === '/login') {
    next()
  } else {
    /**
     * 如果是其他界面, 判断本地是否存在 Token
     * 如果存在, 则正常跳转
     * 否则重定向到 login 界面
     */
    let token = localStorage.getItem('authorization')
    if (!token) {
      next('/login')
    } else {
      next()
    }
  }
})

export default router

最后是网络请求相关的设置:

import axios from 'axios'
import router from '../router'

export function request(config) {

  const req = axios.create({
    baseURL: 'http://localhost:8888',
    timeout: 5000
  })

  // 将所有的网络请求头都带上本地存储的 Token
  req.interceptors.request.use(config => {
    const token = localStorage.getItem('authorization')
    token && (config.headers.authorization = token)
    return config
  })

  /**
   * 对所有请求得到的响应结果获取其中的 Token
   * 如果返回不为空, 则说明本地 Token 未失效, 重新设置本地 Token
   * 否则清除本地 Token, 并跳转到 login 界面
   */
  req.interceptors.response.use(response => {
    const token = response.headers.authorization
    if (token) {
      localStorage.setItem('authorization', token)
    } else {
      localStorage.removeItem('authorization')
      router.push('/login')
    }
    return response.data
  })

  return req(config)
}

总结

本文简单介绍了前后端分离项目集成 JWT 以及 Token 刷新的机制,由于经验不足,可能思路有很多错误之处,欢迎留言指正。

参考资料

本文实现的思路参考了以下博客:

  • https://www.cnblogs.com/zxcoder/p/11964433.html
  • https://blog.csdn.net/qq_38345296/article/details/99598148

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/5376.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!