1.需求背景以及JWT简介
- 现实场景中,有些功能是需要登录才能访问的,比如购物车,个人订单等等。登录功能是最常见的功能。
(1)什么是JWT
JWT 是一个开放标准,它定义了一种用于简洁,自包含的用于通信双方之间以 JSON 对象的形式安全传递信息的方法。 可以使用 HMAC 算法或者是 RSA 的公钥密钥对进行签名。
简单来说: 就是通过一定规范来生成token,然后可以通过解密算法逆向解密token,这样就可以获取用户信息
- 优点
- 生产的token可以包含基本信息,比如id、用户昵称、头像等信息,避免再次查库
- 存储在客户端,不占用服务端的内存资源
- 缺点
- token是经过base64编码,所以可以解码,因此token加密前的对象不应该包含敏感信息,如用户权限,密码等
- 如果没有服务端存储,则不能做登录失效处理,除非服务端改秘钥
- JWT格式组成 头部、负载、签名
- header+payload+signature
- 头部:主要是描述签名算法
- 负载:主要描述是加密对象的信息,如用户的id等,也可以加些规范里面的东西,如iss签发者,exp 过期时间,sub 面向的用户
- 签名:主要是把前面两部分进行加密,防止别人拿到token进行base解密后篡改token
- 关于jwt客户端存储
- 可以存储在cookie,localstorage和sessionStorage里面
(2)用户登录流程图
2.创建Maven项目,搭建SpringBoot项目
(1)添加maven依赖
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.7</version> <relativePath/> <!-- lookup parent from repository --> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.20</version> <scope>compile</scope> </dependency> </dependencies>
(2)创建yml配置文件
server: port: 8001 spring: application: name: login-server
(3)创建运行主类
@SpringBootApplication public class LoginApplication { public static void main(String[] args) { SpringApplication.run(LoginApplication.class, args); } }
3.容器化急速部署MySQL
(1)创建目录
mkdir -p /usr/local/docker/mysql/conf mkdir -p /usr/local/docker/mysql/logs mkdir -p /usr/local/docker/mysql/data
(2)容器启动mysql服务
docker run -p 3306:3306 --name mysql \ -e MYSQL_ROOT_PASSWORD=123456 \ -d mysql:8.0
#查看容器 docker ps
(3)可视化工具连接
4.数据库表准备
(1)创建数据库user库
(2)创建测试用户表
- 创建表sql脚本
/* Navicat Premium Data Transfer Source Server : mysql_test Source Server Type : MySQL Source Server Version : 80027 Source Host : 192.168.139.100:3306 Source Schema : user Target Server Type : MySQL Target Server Version : 80027 File Encoding : 65001 Date: 15/11/2022 09:14:11 */ SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for user -- ---------------------------- DROP TABLE IF EXISTS `user`; CREATE TABLE `user` ( `id` int(0) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '用户名', `password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '密码', `phone` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '手机号', `name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '用户姓名', `sex` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '用户性别', `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间', `age` int(0) NULL DEFAULT NULL COMMENT '年龄', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of user -- ---------------------------- SET FOREIGN_KEY_CHECKS = 1;
(3)创建测试登录用户
- **注意:**这里只是做演示,正常企业密码不会设置明文的,按照企业自己的加密方式去加密密码,我们现在主要是为了开发登录鉴权这一套流程。
INSERT INTO `user`.`user`(`id`, `username`, `password`, `phone`, `name`, `sex`, `create_time`, `age`) VALUES (1, 'lixiang', '1234567890', '13830567835', '李祥', '男', '2022-11-15 09:19:26', 18);
5.SpringBoot整合MySQL+MyBatisPlus
(1)添加maven依赖
<!--mybatis plus--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.1.18</version> </dependency>
(2)配置yml文件
datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.139.100:3306/user?allowPublicKeyRetrieval=true&characterEncoding=UTF-8&allowMultiQueries=true&useUnicode=true&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 type: com.alibaba.druid.pool.DruidDataSource druid: initial-size: 8 max-active: 20 max-wait: 60000 min-evictable-idle-time-millis: 30000
(3)启动主类添加MapperScan()注解
@MapperScan("com.lixiang.mapper")
(4)启动验证
6.MyBatisPlus逆向工程自动生成
(1)加入maven依赖
<!-- 代码自动生成依赖 begin --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-generator</artifactId> <version>3.4.1</version> </dependency> <!-- velocity --> <dependency> <groupId>org.apache.velocity</groupId> <artifactId>velocity-engine-core</artifactId> <version>2.0</version> </dependency> <!-- 代码自动生成依赖 end-->
(2)运行代码
package com.lixiang.db; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.generator.AutoGenerator; import com.baomidou.mybatisplus.generator.config.DataSourceConfig; import com.baomidou.mybatisplus.generator.config.GlobalConfig; import com.baomidou.mybatisplus.generator.config.PackageConfig; import com.baomidou.mybatisplus.generator.config.StrategyConfig; import com.baomidou.mybatisplus.generator.config.rules.DateType; import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy; /** * @description mybatis自定生成工具 */ public class MyBatisPlusGenerator { public static void main(String[] args) { //1. 全局配置 GlobalConfig config = new GlobalConfig(); // 是否支持AR模式 config.setActiveRecord(true) // 作者 .setAuthor("lixiang") // 生成路径,最好使用绝对路径,window路径是不一样的 //TODO TODO TODO TODO .setOutputDir("D:\\IDEAWork\\springboot-login\\src\\test\\java") // 文件覆盖 .setFileOverride(true) // 主键策略 .setIdType(IdType.AUTO) .setDateType(DateType.ONLY_DATE) // 设置生成的service接口的名字的首字母是否为I,默认Service是以I开头的 .setServiceName("%sService") //实体类结尾名称 .setEntityName("%sDO") //生成基本的resultMap .setBaseResultMap(true) //不使用AR模式 .setActiveRecord(false) //生成基本的SQL片段 .setBaseColumnList(true); //2. 数据源配置 DataSourceConfig dsConfig = new DataSourceConfig(); // 设置数据库类型 dsConfig.setDbType(DbType.MYSQL) .setDriverName("com.mysql.cj.jdbc.Driver") //TODO 修改数据库对应的配置 .setUrl("jdbc:mysql://121.36.81.39:3306/user?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai") .setUsername("root") .setPassword("123456"); //3. 策略配置globalConfiguration中 StrategyConfig stConfig = new StrategyConfig(); //全局大写命名 stConfig.setCapitalMode(true) // 数据库表映射到实体的命名策略 .setNaming(NamingStrategy.underline_to_camel) //使用lombok .setEntityLombokModel(true) //使用RestController注解 .setRestControllerStyle(true) // 生成的表, 支持多表一起生成,以数组形式填写 //TODO TODO TODO TODO .setInclude("user"); //4. 包名策略配置 PackageConfig pkConfig = new PackageConfig(); pkConfig.setParent("com.lixiang") .setMapper("mapper") .setService("service") .setController("controller") .setEntity("model") .setXml("mapper"); //5. 整合配置 AutoGenerator ag = new AutoGenerator(); ag.setGlobalConfig(config) .setDataSource(dsConfig) .setStrategy(stConfig) .setPackageInfo(pkConfig); //6. 执行操作 ag.execute(); System.out.println("======= 相关代码生成完毕 ========"); } }
(3)启动验证
7.SpringBoot整合JWT
(1)添加maven依赖
<!-- JWT相关 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.7.0</version> </dependency>
(2)编写登录用户类
/** * 登录user实体bean * @author lixiang * @since 2022-01-13 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class LoginUser { /** * 主键 */ private Long id; /** * 用户名 */ private String username; /** * 姓名 */ private String name; /** * 手机号 */ private String phone; /** * 用户性别 */ private String sex; /** * 年龄 */ private Integer age; }
(3)编写JWTUtil
/** * JWT工具类 * @author lixiang * @since 2022-01-13 */ @Slf4j public class JWTUtil { /** * token过期时间,正常是7天 */ private static final long EXPIRE = 1000L * 60 * 60 * 24 * 7; /** * 加密的密钥 */ private static final String SECRET = "lixiang.com"; /** * 令牌前缀 */ private static final String TOKEN_PREFIX = "LONGIN-TEST"; /** * subject 颁布地址 */ private static final String SUBJECT = "lixiang"; /** * 根据用户信息生成token * @param loginUser * @return */ public static Map<String,Object> geneJsonWebToken(LoginUser loginUser){ if(loginUser == null){ throw new NullPointerException("loginUser对象为空"); } Date endDate = new Date(System.currentTimeMillis() + EXPIRE); String token = Jwts.builder().setSubject(SUBJECT) .claim("age",loginUser.getAge()) .claim("id",loginUser.getId()) .claim("name",loginUser.getName()) .claim("phone",loginUser.getPhone()) .claim("sex",loginUser.getSex()) .setIssuedAt(new Date()) .setExpiration(endDate) .signWith(SignatureAlgorithm.HS256,SECRET) .compact(); token = TOKEN_PREFIX+token; Map<String,Object> map = new HashMap<>(); map.put("accessToken",token); map.put("accessTokenExpires",endDate); return map; } /** * 检验token方法 * @param token * @return */ public static Claims checkJWT(String token){ try{ return Jwts.parser().setSigningKey(SECRET) .parseClaimsJws(token.replace(TOKEN_PREFIX, "")) .getBody(); }catch (Exception e){ log.info("JWT token解密失败"); return null; } } }
(4)测试JWT,编写测试方法
public class Main { public static void main(String[] args) { LoginUser loginUser = LoginUser.builder() .age(18) .id(1L) .name("李祥") .phone("13820934720") .sex("男") .username("lixiang") .build(); Map<String, Object> objectMap = JWTUtil.geneJsonWebToken(loginUser); System.out.println("LoginUser加密:"); objectMap.forEach((k,v)->{ System.out.println("---key:"+k+",value:"+v); }); String accessToken = String.valueOf(objectMap.get("accessToken")); System.out.println("Token解密:"); Claims claims = JWTUtil.checkJWT(accessToken); System.out.println("---name:"+claims.get("name")); System.out.println("---age:"+claims.get("age")); System.out.println("---phone:"+claims.get("phone")); System.out.println("---username:"+claims.get("username")); System.out.println("---sex:"+claims.get("sex")); } }
8.开发测试接口
- 开发两个接口,我们的目的是一个用于不需要登录就能访问,一个需要登录才能访问
- 查看商品信息列表,查看个人订单信息两个接口
- 开发测试的UserController,这块全部用的测试接口,主要是给大家演示效果,现在我们想让商品列表的接口可以随便访问,订单列表的接口只有用户登录之后才能访问。
/** * @description 测试Controller * @author lixiang */ @RestController @RequestMapping("/user") public class UserController { /** * 查询商品列表 * @return */ @GetMapping("/product_list") public Object getProductList(){ return getResult(200,"查询商品列表"); } /** * 查询订单列表 * @return */ @GetMapping("/order_list") public Object getOrderList(){ return getResult(200,"查询订单列表"); } /** * 测试返回结果 * @param code * @param msg * @return */ private Object getResult(int code, String msg) { Map<String,Object> result = new HashMap<>(); result.put("code",code); result.put("msg",msg); return result; } }
- 测试
- 两个接口访问正常,但是对于订单接口我们是想增加Token检验,才会给予访问,这块我们就需要写一个拦截器,但是我们现在应该先去开发一下登录的接口。
9.开发登录接口
- 这块我们采用手机号和密码登录
(1)创建登录请求类
@Data @NoArgsConstructor @AllArgsConstructor public class LoginReq { /** * 手机号 */ private String phone; /** * 密码 */ private String password; }
(2)创建login方法在UserServie
public interface UserService { /** * 登录方法 * @param req * @return */ Map<String, Object> login(LoginReq req); }
(3)login实现类编写
@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override public Map<String, Object> login(LoginReq req) { UserDO user = userMapper.selectOne(new QueryWrapper<UserDO>().eq("phone", req.getPhone())); Map<String,Object> result = new HashMap<>(); //判断是否已经注册的 if (user == null) { //未注册 result.put("code",10000); result.put("msg","用户未注册"); return result; } if (req.getPassword().equals(user.getPassword())) { //登录成功,生成token,UUID生成token,存储到redis中并设置过期时间 LoginUser loginUser = LoginUser.builder().build(); BeanUtils.copyProperties(user, loginUser); return JWTUtil.geneJsonWebToken(loginUser); } result.put("code",10000); result.put("msg","密码错误"); return result; } }
(4)编写Controller
@Autowired private UserService userService; /** * 登录 * @return */ @PostMapping("/login") public Object login(@RequestBody LoginReq req){ return userService.login(req); }
(5)测试登录接口
10.开发登录拦截器
- 登录接口开发完成了,那么我们需要开发一个登录拦截器。
/** * 全局登录拦截器 * @author lixiang */ @Slf4j public class LoginInterceptor implements HandlerInterceptor { public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>(); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //从请求头中拿token String accessToken = request.getHeader("token"); //从请求参数中拿token if (accessToken == null){ accessToken = request.getParameter("token"); } if(accessToken!=null && !accessToken.equals("")){ //不为空,判断是否登录过期 Claims claims = JWTUtil.checkJWT(accessToken); if (claims == null){ sendJsonMessage(response, "账号已过期"); return false; } Long userId = Long.valueOf(claims.get("id").toString()); String headImg = (String) claims.get("username"); String name = (String) claims.get("name"); String phone = (String) claims.get("phone"); String sex = (String) claims.get("sex"); Integer age = (Integer) claims.get("age"); //设置LoginUser对象属性,建造者模式 LoginUser loginUser = LoginUser.builder() .name(name) .username(headImg) .id(userId) .phone(phone) .sex(sex) .age(age).build(); //通过threadLocal共享用户登录信息 threadLocal.set(loginUser); return true; } sendJsonMessage(response, "账号未登录"); return false; } /** * * @param response * @param msg */ private void sendJsonMessage(HttpServletResponse response, String msg) { Map<String,Object> result = new HashMap<>(); result.put("code",10000); result.put("msg",msg); ObjectMapper objectMapper = new ObjectMapper(); response.setContentType("application/json;charset=utf-8"); PrintWriter writer = null; try { writer = response.getWriter(); writer.print(objectMapper.writeValueAsString(result)); response.flushBuffer(); } catch (IOException e) { log.warn("响应json数据给前端异常"); }finally { if(writer!=null){ writer.close(); } } } }
(2)配置接口拦截
/** * 登录拦截配置类 * @author lixiang */ @Configuration @Slf4j public class InterceptorConfig implements WebMvcConfigurer { @Bean public LoginInterceptor loginInterceptor() { return new LoginInterceptor(); } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor()) .addPathPatterns("/user/*") //排查不拦截的路径 .excludePathPatterns("/user/login","/user/product_list"); } }
配置拦截器使用户登录的接口和商品列表查询的接口不进行token验证,将用户的信息放在ThreadLocal中保证每个线程独立内存空间。
11.启动验证
至此,整个登录整合JWT功能已经开发完成,这块其实还可以根据自己的业务去返回一个RefreshToken,Token过期刷新的token。