博客系统
- 前⾔
- 项⽬介绍
- 1. 准备⼯作
- 1.1 数据准备
- 1.2 创建项⽬
- 1.3 准备前端⻚⾯
- 1.4 配置配置⽂件
- 1.5 测试
- 2. 项⽬公共模块
- 2.1 实体类的编写
- 2.2 公共层
- 3. 业务代码
- 3.1 持久层
- 3.2 实现博客列表
- 3.3 实现博客详情
- 3.4 实现登陆
- 令牌技术
- JWT令牌
- 介绍
- JWT令牌⽣成和校验
- 3.5 实现强制要求登陆
- 3.6 实现显⽰⽤⼾信息
- 3.7 实现⽤⼾退出
- 3.8 实现发布博客
- editor.md 简单介绍
- 3.9 实现删除/编辑博客
- 3.10 加密/加盐
前⾔
结合前面学习的JavaEE 与 Spring 基础, 从 0 到 1 实现一个博客系统, 练习前面学习的知识!
项⽬介绍
使⽤SSM框架实现⼀个简单的博客系统
共5个⻚⾯
- ⽤⼾登录
- 博客发表⻚
- 博客编辑⻚
- 博客列表⻚
- 博客详情⻚
功能描述:
⽤⼾登录成功后, 可以查看所有⼈的博客. 点击 <<查看全⽂>> 可以查看该博客的正⽂内容. 如果该博客作者为当前登录⽤⼾, 可以完成博客的修改和删除操作, 以及发表新博客
⻚⾯预览
⽤⼾登录
博客列表⻚
博客详情⻚
博客发表/修改⻚
1. 准备⼯作
1.1 数据准备
建表SQL
-- 建表SQL
CREATE DATABASEIF NOT EXISTS java_blog_spring charset utf8mb4;USE java_blog_spring;-- ⽤⼾表
DROP TABLEIF EXISTS java_blog_spring.USER;CREATE TABLE java_blog_spring.USER
(`id` INT NOT NULL AUTO_INCREMENT,`user_name` VARCHAR(128) NOT NULL,`password` VARCHAR(128) NOT NULL,`github_url` VARCHAR(128) NULL,`delete_flag` TINYINT(4) NULL DEFAULT 0,`create_time` DATETIME DEFAULT now(),`update_time` DATETIME DEFAULT now(),PRIMARY KEY (id),UNIQUE INDEX user_name_UNIQUE (user_name ASC)
) ENGINE = INNODBDEFAULT CHARACTERSET = utf8mb4 COMMENT = '⽤⼾表';-- 博客表
DROP TABLEIF EXISTS java_blog_spring.blog;CREATE TABLE java_blog_spring.blog
(`id` INT NOT NULL AUTO_INCREMENT,`title` VARCHAR(200) NULL,`content` TEXT NULL,`user_id` INT(11) NULL,`delete_flag` TINYINT(4) NULL DEFAULT 0,`create_time` DATETIME DEFAULT now(),`update_time` DATETIME DEFAULT now(),PRIMARY KEY (id)
) ENGINE = INNODBDEFAULT CHARSET = utf8mb4 COMMENT = '博客表';-- 新增⽤⼾信息
INSERT INTO java_blog_spring.USER (user_name,PASSWORD,github_url)
VALUES ("zhangsan","123456","https://gitee.com/bubblefish666/class-java45");INSERT INTO java_blog_spring.USER (user_name,PASSWORD,github_url)
VALUES ("lisi","123456","https://gitee.com/bubblefish666/class-java45");INSERT INTO java_blog_spring.blog (title, content, user_id)
VALUES ("第一篇博客","111我是博客正文我是博客正文我是博客正文",1);INSERT INTO java_blog_spring.blog (title, content, user_id)
VALUES ("第二篇博客","222我是博客正⽂我是博客正文我是博客正文",2);
1.2 创建项⽬
创建SpringBoot项⽬, 添加Spring MVC 和MyBatis对应依赖
1.3 准备前端⻚⾯
前端页面代码提取仓库
1.4 配置配置⽂件
spring:datasource:url: jdbc:mysql://127.0.0.1:3306/trans_test?characterEncoding=utf8&useSSL=falseusername: rootpassword: 123456driver-class-name: com.mysql.cj.jdbc.Drivermybatis:configuration: # 配置打印 MyBatis日志log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case: true #配置驼峰自动转换# 配置 mybatis xml 的文件路径,在 resources/mapper 创建所有表的 xml 文件mapper-locations: classpath:mapper/**Mapper.xml
1.5 测试
测试程序启动后, 是否可以正常访问前端页面
http://127.0.0.1:8080/blog_login.html
前端⻚⾯可以正确显⽰, 说明项⽬初始化成功.
2. 项⽬公共模块
项⽬分为控制层(Controller), 服务层(Service), 持久层(Mapper). 各层之间的调⽤关系如下:
我们先根据需求完成实体类和公共层代码的编写
2.1 实体类的编写
package cn.edu.zxj.springblog.model;import lombok.Data;import java.util.Date;/*** Created with IntelliJ IDEA.* Description:博客信息相关的实体类** @author: zxj* @date: 2024-02-04* @time: 17:42:19*/
@Data
public class BlogInfo {private Integer id;private String title;private String content;private Integer userId;private Integer deleteFlag;private Date createTime;private Date updateTime;
}
package cn.edu.zxj.springblog.model;import lombok.Data;import java.util.Date;/*** Created with IntelliJ IDEA.* Description:用户相关的实体类** @author: zxj* @date: 2024-02-04* @time: 17:44:23*/
@Data
public class UserInfo {private Integer id;private String userName;private String password;private String githubUrl;private Integer deleteFlag;private Date createTime;private Date updateTime;
}
2.2 公共层
- 统⼀返回结果实体类
- code: 业务状态码
- 200: 业务处理成功
- -1 : 业务处理失败
- -2 : ⽤⼾未登录
- 后续有其他异常信息, 可以再补充.
- msg: 业务处理失败时, 返回的错误信息
- data: 业务返回数据
- code: 业务状态码
定义业务状态码
package cn.edu.zxj.springblog.common;/*** Created with IntelliJ IDEA.* Description:定义业务状态码** @author: zxj* @date: 2024-02-04* @time: 17:49:06*/
public class Constants {public static final Integer RESULT_CODE_SUCCESS = 200;public static final Integer RESULT_CODE_FAIL = -1;public static final Integer RESULT_CODE_UN_LOGIN = -2;
}
package cn.edu.zxj.springblog.model;import cn.edu.zxj.springblog.common.Constants;
import lombok.Data;/*** Created with IntelliJ IDEA.* Description:统一返回结果的实体类:** @author: zxj* @date: 2024-02-04* @time: 17:51:46*/
@Data
public class Result<T> {// 业务状态码private Integer code;// 错误描述private String errorMessage;// 返回的数据private T data;/*** @description: 业务处理流程成功**/public static <T> Result<T> success(T data) {Result<T> result = new Result<>();result.setCode(Constants.RESULT_CODE_SUCCESS);result.setData(data);result.setErrorMessage("");return result;}/*** @description: 业务处理流程失败**/public static <T> Result<T> fail(String errorMessage) {Result<T> result = new Result<>();result.setCode(Constants.RESULT_CODE_SUCCESS);result.setErrorMessage(errorMessage);return result;}/*** @description: 业务处理流程失败, 失败时带回一些数据**/public static <T> Result<T> fail(String errorMessage,T data) {Result<T> result = new Result<>();result.setCode(Constants.RESULT_CODE_SUCCESS);result.setData(data);result.setErrorMessage(errorMessage);return result;}/*** @description: 用户未登录**/public static <T> Result<T> fail() {Result<T> result = new Result<>();result.setCode(Constants.RESULT_CODE_UN_LOGIN);result.setErrorMessage("用户未登录");return result;}
}
- 统⼀返回结果
package cn.edu.zxj.springblog.config;import cn.edu.zxj.springblog.model.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;/*** Created with IntelliJ IDEA.* Description:设置统一返回类** @author: zxj* @date: 2024-02-04* @time: 18:00:36*/
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {@Overridepublic boolean supports(MethodParameter returnType, Class converterType) {// 启用统一结果返回return true;}@SneakyThrows@Overridepublic Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {// 对body进行处理if (body instanceof Result) {return body;}// 对 String 类型特殊处理if (body instanceof String) {ObjectMapper objectMapper = new ObjectMapper();return objectMapper.writeValueAsString(Result.success(body)); }return Result.success(body);}
}
- 统⼀异常处理
package cn.edu.zxj.springblog.config;import cn.edu.zxj.springblog.model.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;/*** Created with IntelliJ IDEA.* Description:** @author: zxj* @date: 2024-02-04* @time: 18:05:44*/
@ControllerAdvice
@Slf4j
@ResponseBody
public class ErrorAdvice {@ExceptionHandlerpublic Result exceptionAdvice(Exception e) {log.error("发生错误, e: {}", e);return Result.fail("内部发生错误, 请联系管理员");}
}
3. 业务代码
3.1 持久层
根据需求, 先⼤致计算有哪些DB相关操作, 完成持久层初步代码, 后续再根据业务需求进⾏完善
- ⽤⼾登录
- 依据用户名来查找用户信息
- 博客发表⻚
- 根据id查询user信息
- 获取所有博客列表
- 博客编辑⻚
- 根据博客ID查询博客 信息
- 根据博客ID删除博客(修改 delete_flag = 1)
- 博客列表⻚
- 根据博客ID修改博客信息
- 发表博客
- 插入新的
依据上述分析的数据操作写mapper层的代码
package cn.edu.zxj.springblog.mapper;import cn.edu.zxj.springblog.model.BlogInfo;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;import java.util.List;/*** Created with IntelliJ IDEA.* Description:针对Blog相关的数据库操作** @author: zxj* @date: 2024-02-04* @time: 18:15:52*/
@Mapper
public interface BlogInfoMapper {/*** @description: 获取所有博客列表**/@Select("select id, title, content,user_id,delete_flag,create_time,update_time " +"from blog where delete_flag = 0")List<BlogInfo> selectAll();/*** @description: 根据博客ID查询博客信息**/@Select("select id, title, content,user_id,delete_flag,create_time,update_time " +"from blog where delete_flag = 0 and id = #{id}")BlogInfo selectById(Integer id);/*** @description: 删除博客, 修改 delete_flag 字段为1**/@Update("update blog set delete_flag = 1 where id = #{id}")Integer delete(Integer id);/*** @description: 编辑博客**/Integer update(BlogInfo blogInfo);/*** @description: 插入新的博客**/@Insert("insert into blog (title, content, user_id) values (#{title},#{content},#{userId})")Integer insert(BlogInfo blogInfo);
}
package cn.edu.zxj.springblog.mapper;import cn.edu.zxj.springblog.model.UserInfo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;/*** Created with IntelliJ IDEA.* Description:针对 user 相关的数据库操作** @author: zxj* @date: 2024-02-04* @time: 18:15:44*/
@Mapper
public interface UserInfoMapper {/*** @description: 依据用户名查询用户信息**/@Select("select id, user_name, password, github_url, delete_flag, create_time, update_time" +" from user where delete_flag = 0 and user_name = #{name}")UserInfo selectByName(String name);/*** @description: 已经 ID 查询用户信息**/@Select("select id, user_name, password, github_url, delete_flag, create_time, update_time" +" from user where delete_flag = 0 and id = #{id}")UserInfo selectById(Integer id);}
BlogInfoMapper.xml 相关内容
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.edu.zxj.springblog.mapper.BlogInfoMapper"><update id="update">update blog<set><if test="content != null">content = #{content},</if><if test="title != null">title = #{title}</if></set>where id = #{id}</update></mapper>
书写测试用例, 确保 Mapper层的代码的正确性
package cn.edu.zxj.springblog.mapper;import cn.edu.zxj.springblog.model.BlogInfo;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;import static org.junit.jupiter.api.Assertions.*;/*** Created with IntelliJ IDEA.* Description:测试 BlogInfoMapper** @author: zxj* @date: 2024-02-04* @time: 18:33:27*/
@SpringBootTest
class BlogInfoMapperTest {@Autowiredprivate BlogInfoMapper blogInfoMapper;@Testvoid selectAll() {System.out.println(blogInfoMapper.selectAll());}@Testvoid selectById() {System.out.println(blogInfoMapper.selectById(1));}@Testvoid delete() {System.out.println(blogInfoMapper.delete(3));}@Testvoid update() {BlogInfo blogInfo = new BlogInfo();blogInfo.setTitle("测试添加博客111111111000");blogInfo.setContent("测试内容222000");blogInfo.setId(3);blogInfoMapper.update(blogInfo);}@Testvoid insert() {BlogInfo blogInfo = new BlogInfo();blogInfo.setTitle("测试添加博客");blogInfo.setContent("测试内容");blogInfo.setUserId(1);blogInfoMapper.insert(blogInfo);}
}
package cn.edu.zxj.springblog.mapper;import cn.edu.zxj.springblog.model.UserInfo;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;import static org.junit.jupiter.api.Assertions.*;/*** Created with IntelliJ IDEA.* Description:测试 UserInfoMapper** @author: zxj* @date: 2024-02-04* @time: 18:33:37*/
@SpringBootTest
class UserInfoMapperTest {@Autowiredprivate UserInfoMapper userInfoMapper;@Testvoid selectByName() {UserInfo userInfo = userInfoMapper.selectByName("zhangsan");System.out.println(userInfo);}@Testvoid selectById() {System.out.println(userInfoMapper.selectById(6));}
}
3.2 实现博客列表
约定前后端交互接⼝
客⼾端给服务器发送⼀个 /blog/getlist 这样的 HTTP 请求, 服务器给客⼾端返回了⼀个 JSON 格式的数据.
实现服务器代码
Controller 层
@RequestMapping("/getList")public List<BlogInfo> getList() {log.info("接收到获取所有博客信息请求");return blogInfoService.getList();}
Service 层
public List<BlogInfo> getList() {try {return blogInfoMapper.selectAll();} catch (Exception e){log.error("查询 blog 所有信息失败, e: {}",e);}return null;}
实现客⼾端代码
修改 blog_list.html, 删除之前写死的博客内容(即 <div class=“blog”> ), 并新增 js 代码处理ajax 请求.
- 使⽤ ajax 给服务器发送 HTTP 请求.
- 服务器返回的响应是⼀个 JSON 格式的数据, 根据这个响应数据使⽤ DOM API 构造⻚⾯内容.
- 响应中的 postTime 字段为 ms 级时间戳, 需要转成格式化⽇期.
- 跳转到博客详情⻚的 url 形如 blog_detail.html?blogId=1 这样就可以让博客详情⻚知道当前是要访问哪篇博客.
此时⻚⾯的⽇期显⽰为时间戳, 我们从后端也⽇期进⾏处理
public static String dateFormat(Date date){// 创建SimpleDateFormat对象,并指定想要的日期格式SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");// 使用format()方法将Date对象转换为字符串return dateFormat.format(date);}
重写获取博客创建时间
通过 URL http://127.0.0.1:8080/blog_list.html 访问服务器, 验证效果
3.3 实现博客详情
⽬前点击博客列表⻚的 “查看全⽂” , 能进⼊博客详情⻚, 但是这个博客详情⻚是写死的内容. 我们期望能够根据当前的 博客 id 从服务器动态获取博客内容.
约定前后端交互接⼝
实现服务器代码
Controller 层
@RequestMapping("/getBlogDetail")public BlogInfo getBlogDetail(Integer blogId) {log.info("接收到获取博客详细信息请求, blogId: {}",blogId);// 1. 参数校验if (blogId < 1) {return null;}// 2. 进行服务return blogInfoService.getBlogDetail(blogId);}
Service 层
public BlogInfo getBlogDetail(Integer blogId) {try {return blogInfoMapper.selectById(blogId);} catch (Exception e) {log.error("查询 blogId: {} 详细信息失败, e: {}", blogId, e);}return null;}
部署程序, 验证服务器是否能正确返回数据
实现客⼾端代码
修改 blog_content.html
- 根据当前⻚⾯ URL 中的 blogId 参数(使⽤ location.search 即可得到形如 ?blogId=1 的数据), 给服务器发送 GET /blog 请求.
- 根据获取到的响应数据, 显⽰在⻚⾯上
3.4 实现登陆
分析
传统思路:
- 登录页面的用户名密码提交给服务器.
- 服务器验证用户密码是否正确, 并返回校验结果给后端
- 如果密码正确, 则在服务器端创建 Session, 通过 Cookie 把 sessionId 返回给游览器.
问题:
集群环境下⽆法直接使⽤Session.
原因分析:
我们开发的项⽬, 在企业中很少会部署在⼀台机器上, 容易发⽣单点故障. (单点故障: ⼀旦这台服务器挂了, 整个应⽤都没法访问了). 所以通常情况下, ⼀个Web应⽤会部署在多个服务器上, 通过Nginx等进⾏负载均衡. 此时, 来⾃⼀个⽤⼾的请求就会被分发到不同的服务器上.
假如我们使⽤Session进⾏会话跟踪, 我们来思考如下场景:
- ⽤⼾登录 ⽤⼾登录请求, 经过负载均衡, 把请求转给了第⼀台服务器, 第⼀台服务器进⾏账号密码验证, 验证成功后, 把Session存在了第⼀台服务器上
- 查询操作 ⽤⼾登录成功之后, 携带Cookie(⾥⾯有SessionId)继续执⾏查询操作, ⽐如查询博客列表. 此时请求转发到了第⼆台机器, 第⼆台机器会先进⾏权限验证操作(通过SessionId验证⽤⼾是否登录), 此时第⼆台机器上没有该⽤⼾的Session, 就会出现问题, 提⽰⽤⼾登录, 这是⽤⼾不能忍受的.
接下来我们介绍第三种⽅案: 令牌技术
令牌技术
令牌其实就是⼀个⽤⼾⾝份的标识, 名称起的很⾼⼤上, 其实本质就是⼀个字符串.
⽐如我们出⾏在外, 会带着⾃⼰的⾝份证, 需要验证⾝份时, 就掏出⾝份证⾝份证不能伪造, 可以辨别真假.
服务器具备⽣成令牌和验证令牌的能⼒
我们使⽤令牌技术, 继续思考上述场景:
- ⽤⼾登录 ⽤⼾登录请求, 经过负载均衡, 把请求转给了第⼀台服务器, 第⼀台服务器进⾏账号密码验证, 验证成功后, ⽣成⼀个令牌, 并返回给客⼾端.
- 客⼾端收到令牌之后, 把令牌存储起来. 可以存储在Cookie中, 也可以存储在其他的存储空间(⽐如localStorage)
- 查询操作 ⽤⼾登录成功之后, 携带令牌继续执⾏查询操作, ⽐如查询博客列表. 此时请求转发到了第⼆台机器, 第⼆台机器会先进⾏权限验证操作. 服务器验证令牌是否有效, 如果有效, 就说明⽤⼾已经执⾏了登录操作, 如果令牌是⽆效的, 就说明⽤⼾之前未执⾏登录操作.
令牌的优缺点
优点:
- 解决了集群环境下的认证问题
- 减轻服务器的存储压⼒(⽆需在服务器端存储)
缺点:
需要⾃⼰实现(包括令牌的⽣成, 令牌的传递, 令牌的校验)
当前企业开发中, 解决会话跟踪使⽤最多的⽅案就是令牌技术.
JWT令牌
令牌本质就是⼀个字符串, 他的实现⽅式有很多, 我们采⽤⼀个JWT令牌来实现.
介绍
JWT全称: JSON Web Token
官⽹: https://jwt.io/
JWT组成
JWT由三部分组成, 每部分中间使⽤点 (.) 分隔,⽐如:aaaaa.bbbbb.cccc
- Header(头部) 头部包括令牌的类型(即JWT)及使⽤的哈希算法(如HMAC SHA256或RSA)
- Payload(负载) 负载部分是存放有效信息的地⽅, ⾥⾯是⼀些⾃定义内容.
- ⽐如:{“userId”:“123”,“userName”:“zhangsan”} , 也可以存在jwt提供的现场字段, ⽐如exp(过期时间戳)等.
此部分不建议存放敏感信息, 因为此部分可以解码还原原始内容.
- Signature(签名) 此部分⽤于防⽌jwt内容被篡改, 确保安全性.
防⽌被篡改, ⽽不是防⽌被解析.
JWT之所以安全, 就是因为最后的签名. jwt当中任何⼀个字符被篡改, 整个令牌都会校验失败.
就好⽐我们的⾝份证, 之所以能标识⼀个⼈的⾝份, 是因为他不能被篡改, ⽽不是因为内容加密.(任何⼈都可以看到⾝份证的信息, jwt 也是)
对上⾯部分的信息, 使⽤Base64Url 进⾏编码, 合并在⼀起就是jwt令牌
Base64是编码⽅式,⽽不是加密⽅式
JWT令牌⽣成和校验
- 引⼊JWT令牌的依赖
<!-- 添加 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>
- 使⽤Jar包中提供的API来完成JWT令牌的⽣成和校验
⽣成令牌
package cn.edu.zxj.springblog;import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;/*** Created with IntelliJ IDEA.* Description:jwt 学习示例** @author: zxj* @date: 2024-02-04* @time: 22:18:59*/
@SpringBootTest
public class JWTUtilsTest {// 过期时间, 单位是 ms, 设置为 30 分钟private static final Long Expiration = 30*60*1000L;// 密钥private static final String secretString = "123456";// 生成安全密钥private static final SecretKey KEY = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretString));/*** @description: 生成令牌**/@Testpublic void genJWT() {Map<String,Object> claim = new HashMap<>();claim.put("name","zhangsan");claim.put("id",1);String token = Jwts.builder().setClaims(claim).setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + Expiration)).signWith(KEY).compact();System.out.println(token);}}
-
注意: 对于密钥有⻓度和内容有要求, 建议使⽤, 密钥太短会报错
-
io.jsonwebtoken.security.Keys#secretKeyFor(signaturealgalgorithm)⽅法来创建⼀个密钥
package cn.edu.zxj.springblog;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.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;import javax.crypto.SecretKey;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;/*** Created with IntelliJ IDEA.* Description:jwt 学习示例** @author: zxj* @date: 2024-02-04* @time: 22:18:59*/
@SpringBootTest
public class JWTUtilsTest {// 过期时间, 单位是 ms, 设置为 30 分钟private static final Long Expiration = 30*60*1000L;// 密钥private static final String secretString = "M6v2NVNUWsHCXB20foSSCquBYMrleVuCbXqVW5fWIgM=";// 生成安全密钥private static final SecretKey KEY = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretString));/*** @description: 生成令牌**/@Testpublic void genJWT() {Map<String,Object> claim = new HashMap<>();claim.put("name","zhangsan");claim.put("id",1);String token = Jwts.builder().setClaims(claim).setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + Expiration)).signWith(KEY).compact();System.out.println(token);}/*** @description: 生成密钥**/@Testpublic void genKey() {Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);String secretStr= Encoders.BASE64.encode(key.getEncoded());// 利用上述得到安全复杂的 secretStringSystem.out.println(secretStr);}public static void main(String[] args) {Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);String secretStr= Encoders.BASE64.encode(key.getEncoded());// 利用上述得到安全复杂的 secretStringSystem.out.println(secretStr);}}
运行程序, 就可以生成 token
- HEADER部分可以看到, 使⽤的算法为HS256
- PAYLOAD部分是我们⾃定义的内容, exp表⽰过期时间
- VERIFY SIGNATURE部分是经过签名算法计算出来的, 所以不会解析
校验令牌
完成了令牌的⽣成, 我们需要根据令牌, 来校验令牌的合法性(以防客⼾端伪造)
/*** @description: 验证 token 的合法性, 解析 token**/@Testpublic void parseJWT() {String token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiemhhbmdzYW4iLCJpZCI6MSwiaWF0IjoxNzA3MDU3Njk2LCJleHAiOjE3MDcwNTk0OTZ9.4xXmir0P5cBGnS0z-fT39MzuhY9ACV8Hjt2yF5Mtgp4";// 创建解析器, 设置签名密钥JwtParserBuilder jwtParserBuilder = Jwts.parserBuilder().setSigningKey(KEY);// 解析tokenClaims claims = jwtParserBuilder.build().parseClaimsJws(token).getBody();System.out.println(claims);}
令牌解析后, 我们可以看到⾥⾯存储的信息,如果在解析的过程当中没有报错,就说明解析成功了.
令牌解析时, 也会进⾏时间有效性的校验, 如果令牌过期了, 解析也会失败.
修改令牌中的任何⼀个字符, 都会校验失败, 所以令牌⽆法篡改
学习令牌的使⽤之后, 接下来我们通过令牌来完成⽤⼾的登录
- 登陆⻚⾯把⽤⼾名密码提交给服务器.
- 服务器端验证⽤⼾名密码是否正确, 如果正确, 服务器⽣成令牌, 下发给客⼾端.
- 客⼾端把令牌存储起来(⽐如Cookie, local storage等), 后续请求时, 把token发给服务器
- 服务器对令牌进⾏校验, 如果令牌正确, 进⾏下⼀步操作
约定前后端交互接⼝
实现服务器代码
创建JWT⼯具类
package cn.edu.zxj.springblog.utils;import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParserBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;import javax.crypto.SecretKey;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;/*** Created with IntelliJ IDEA.* Description:jwt 生成 token, 并检验 token 中的内容** @author: zxj* @date: 2024-02-04* @time: 22:58:08*/
@Slf4j
public class JWTUtils {// 过期时间, 单位是 ms, 设置为 30 分钟private static final Long Expiration = 30*60*1000L;// 密钥, 可以调用下面 genKey 生成, 并复制粘贴private static final String secretString = "M6v2NVNUWsHCXB20foSSCquBYMrleVuCbXqVW5fWIgM=";// 生成安全密钥private static final SecretKey KEY = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretString));/*** @description: 生成令牌**/public static String genJWT(Map<String,Object> claim) {String token = Jwts.builder().setClaims(claim) // 自定义内容(负载).setIssuedAt(new Date()) // 设置签名时间.setExpiration(new Date(System.currentTimeMillis() + Expiration)) // 设置过期时间.signWith(KEY) // 签名算法.compact();return token;}/*** @description: 验证 token 的合法性, 解析 token**/public static Claims parseJWT(String token) {if (token == null) {return null;}// 创建解析器, 设置签名密钥JwtParserBuilder jwtParserBuilder = Jwts.parserBuilder().setSigningKey(KEY);Claims claims = null;try {// 解析tokenclaims = jwtParserBuilder.build().parseClaimsJws(token).getBody();} catch (Exception e) {// 签名认证失败log.error("解析令牌失败, token: {}", token);}return claims;}/*** @description: 生成密钥**/private static void genKey() {Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);String secretStr= Encoders.BASE64.encode(key.getEncoded());// 利用上述得到安全复杂的 secretStringSystem.out.println(secretStr);}/*** @description: 从 token 中获取 id**/public static Integer getUserIdFromToken(String jwtToken) {Claims claims = parseJWT(jwtToken);if (claims != null) {Map<String,Object> map = new HashMap<>(claims);return (Integer) map.get("id");}return null;}}
创建 UserInfoController, 实现 login 路径业务
@RequestMapping("/login")public Result login(String username, String password) {log.info("接收到用户登录请求, username: {}", username);// 1. 参数合法校验if (!StringUtils.hasLength(username)|| !StringUtils.hasLength(password)) {return Result.fail("参数存在问题");}// 2. 判断是否正确// 2.1 调用数据库查询用户信息UserInfo userInfo = userInfoService.selectByUsername(username);// 2.2 判断密码是否正确if (userInfo == null || !password.equals(userInfo.getPassword())) {return Result.fail("用户或者密码错误");}// 3. 生成 token 并返回给前端// 3.1 提取 userInfo 中的相关信息, 存储到 token 中Map<String,Object> claim = new HashMap<>();claim.put("id",userInfo.getId());claim.put("userName",userInfo.getUserName());// 3.2 利用 claim 存储到 token 中String token = JWTUtils.genJWT(claim);log.info("依据 claim: {}, 生成 token: {}",claim,token);return Result.success(token);}
Service 层
public UserInfo selectByUsername(String username) {try {return userInfoMapper.selectByName(username);} catch (Exception e) {log.error("通过用户名查询用户信息出现错误, e: {}",e);}return null;}
实现客⼾端代码
修改 login.html, 完善登录⽅法
前端收到token之后, 保存在localstorage中
local storage相关操作
存储数据
localStorage.setItem("user_token","value");
读取数据
localStorage.getItem("user_token");
删除数据
localStorage.removeItem("user_token");
部署程序, 验证效果.
3.5 实现强制要求登陆
当⽤⼾访问 博客列表⻚ 和 博客详情⻚ 时, 如果⽤⼾当前尚未登陆, 就⾃动跳转到登陆⻚⾯.
我们可以采⽤拦截器来完成, token通常由前端放在header中, 我们从header中获取token, 并校验token是否合法
添加拦截器
package cn.edu.zxj.springblog.config;import cn.edu.zxj.springblog.utils.JWTUtils;
import io.jsonwebtoken.Claims;
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;/*** Created with IntelliJ IDEA.* Description:登录拦截器** @author: zxj* @date: 2024-02-04* @time: 23:45:34*/
@Configuration
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {log.info("正在进行登录拦截校验...");// 1. 从 header 中获得 tokenString token = request.getHeader("user_token");log.info("从 request 中获得 token: {}",token);// 2. 验证 tokenClaims claims = JWTUtils.parseJWT(token);if (claims == null) {// 该 token 是不合法的, 未登录状态, 不放行response.setStatus(401);return false;}// 走到这里, token 合法, 放行return true;}
}
package cn.edu.zxj.springblog.config;import lombok.extern.slf4j.Slf4j;
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;/*** Created with IntelliJ IDEA.* Description:登录拦截器的注册** @author: zxj* @date: 2024-02-04* @time: 23:51:25*/
@Configuration
@Slf4j
public class WebConfig implements WebMvcConfigurer {private static final List<String> excludePath = Arrays.asList("/user/login","/**/*.html","/css/**","/blog-editormd/**","/js/**","/pic/**");@AutowiredLoginInterceptor loginInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(loginInterceptor).addPathPatterns("/**").excludePathPatterns(excludePath);}
}
实现客⼾端代码
- 前端请求时, header中统⼀添加token, 可以写在common.js中
$(document).ajaxSend(function (e,xhr,opt) {var user_token = localStorage.getItem("user_token");xhr.setRequestHeader("user_token",user_token);
});
ajaxSend() ⽅法在 AJAX 请求开始时执⾏函数
- event - 包含 event 对象
- xhr - 包含 XMLHttpRequest 对象
- options - 包含 AJAX 请求中使⽤的选项
- 修改 blog_datail.html
- 访问⻚⾯时, 添加失败处理代码
- 使⽤ location.assign 进⾏⻚⾯跳转
error: function (error) {console.log(error);if (error != null && error.status == 401) {alert("用户未登录, 即将跳转到登录界面");// 已经被拦截器拦截了, 未登录location.href = "blog_login.html";}}
3.6 实现显⽰⽤⼾信息
⽬前⻚⾯的⽤⼾信息部分是写死的. 形如:
我们期望这个信息可以随着⽤⼾登陆⽽发⽣改变.
- 如果当前⻚⾯是博客列表⻚, 则显⽰当前登陆⽤⼾的信息.
- 如果当前⻚⾯是博客详情⻚, 则显⽰该博客的作者⽤⼾信息.
注意: 当前我们只是实现了显⽰⽤⼾名, 没有实现显⽰⽤⼾的头像以及⽂章数量等信息.
约定前后端交互接⼝
在博客列表⻚, 获取当前登陆的⽤⼾的⽤⼾信息.
在博客详情⻚, 获取当前⽂章作者的⽤⼾信息
实现服务器代码
在 UserController添加代码
@RequestMapping("/getUserInfo")public Result getUserInfo(HttpServletRequest request) {log.info("收到获取用户登录信息请求...");// 1. 提取token中的用户IDString token = request.getHeader("user_token");Integer id = JWTUtils.getUserIdFromToken(token);if (id == null || id < 1) {return Result.fail("用户登录状态异常");}// 2. 业务处理UserInfo userInfo = userInfoService.selectById(id);if (userInfo == null) {return Result.fail("用户查询异常");}return Result.success(userInfo);}
/*** @description: 依据博客id 查询 博客信息中的作者 user_id -> 作者信息**/@RequestMapping("/getAuthorInfo")public Result getAuthorInfo(Integer blogId) {log.info("接收到查询博客作者信息请求, blogId", blogId);if (blogId == null || blogId < 1) {return Result.fail("参数存在问题");}UserInfo userInfo = userInfoService.getAuthorInfo(blogId);if (userInfo == null || userInfo.getId() < 1) {return Result.fail("查询用户信息失败");}return Result.success(userInfo);}
Mapper 层
public UserInfo getAuthorInfo(Integer blogId) {try {BlogInfo blogInfo = blogInfoMapper.selectById(blogId);Integer userId = blogInfo.getUserId();return userInfoMapper.selectById(userId);} catch (Exception e) {log.error("依据博客Id获取作者信息, 查询数据库出现错误, e: {}", e);}return null;}
实现客⼾端代码
- 修改 blog_list.html
- 在响应回调函数中, 根据响应中的⽤⼾名, 更新界⾯的显⽰.
- 修改 blog_detail.html
修改⽅式同上
部署程序, 验证效果.
代码整合: 提取common.js
引⼊common.js
blog_list.html 代码修改
blog_detail.html 代码修改
3.7 实现⽤⼾退出
前端直接清除掉token即可.
实现客⼾端代码
<<注销>> 链接已经提前添加了onclick事件
在common.js中完善logout⽅法
3.8 实现发布博客
约定前后端交互接⼝
实现服务器代码
修改 BlogController, 新增 add ⽅法.
@RequestMapping("/add")public Boolean add(BlogInfo blogInfo, HttpServletRequest request) {log.info("接收到添加博客信息请求, blogInfo: {}",blogInfo);// 参数校验if (blogInfo == null) {return false;}// 1. 获取 token 中 UserIdString token = request.getHeader("user_token");Claims claims = JWTUtils.parseJWT(token);if (claims == null) {return false;}Map<String,Object> map = new HashMap<>(claims);Integer userId = (Integer) map.get("id");// 2. 完善 blogInfo 中的信息blogInfo.setUserId(userId);// 3. 处理服务Integer ret = blogInfoService.add(blogInfo);if (ret == null || ret < 0) {return false;}return true;}
BlogService 添加对应的处理逻辑
public Integer add(BlogInfo blogInfo) {try {return blogInfoMapper.insert(blogInfo);} catch (Exception e) {log.error("插入 blogInfo: {} 失败, e: {}", blogInfo, e);}return null;}
editor.md 简单介绍
editor.md 是⼀个开源的⻚⾯ markdown 编辑器组件.
官⽹参⻅: http://editor.md.ipandao.com/
代码: https://pandao.github.io/editor.md/
实现客⼾端代码
修改 blog_edit.html
- 完善submit⽅法
修改详情⻚⻚⾯显⽰
此时会发现详情⻚会显⽰markdown的格式符号, 我们对⻚⾯进⾏也下处理
- 修改 html 部分, 把博客正⽂的 div 标签, 改成 并且加上style=“background-color: transparent;”
2. 修改博客正⽂内容的显⽰
3.9 实现删除/编辑博客
进⼊⽤⼾详情⻚时, 如果当前登陆⽤⼾正是⽂章作者, 则在导航栏中显⽰ [编辑] [删除] 按钮, ⽤⼾点击时则进⾏相应处理.
需要实现两件事:
- 判定当前博客详情⻚中是否要显⽰[编辑] [删除] 按钮
- 实现编辑/删除逻辑
删除采⽤逻辑删除, 所以和编辑其实为同⼀个接⼝
约定前后端交互接⼝
- 判定是否要显⽰[编辑] [删除] 按钮
修改之前的 获取博客 信息的接⼝, 在响应中加上⼀个字段.
- loginUser 为 1 表⽰当前博客就是登陆⽤⼾⾃⼰写的.
- 修改博客
- 删除博客
实现服务器代码
- 给 BlogInfo 类新增⼀个字段
- 修改 BlogController
其他代码不变. 只处理 “getBlogDeatail” 中的逻辑.
@RequestMapping("/getBlogDetail")public BlogInfo getBlogDetail(Integer blogId, HttpServletRequest request) {log.info("接收到获取博客详细信息请求, blogId: {}",blogId);// 1. 参数校验if (blogId < 1) {return null;}// 2. 获取当前登录的IdString token = request.getHeader("user_token");if (token == null) {return null;}Integer loginId = JWTUtils.getUserIdFromToken(token);// 3. 进行服务BlogInfo blogInfo = blogInfoService.getBlogDetail(blogId);if (blogInfo == null) {return null;}// 4. 设置 LoginUser 字段if (loginId != null && loginId.equals(blogInfo.getUserId())) {blogInfo.setLoginUser(1);}return blogInfo;}
- 修改 BlogController
增加 更新删除 功能
@RequestMapping("/update")public Result update(BlogInfo blogInfo) {log.info("接收到更新博客信息请求, blogInfo: {}",blogInfo);// 参数校验if (blogInfo == null) {return Result.fail("参数存在问题",false);}// 业务处理Integer ret = blogInfoService.update(blogInfo);if (ret == null || ret < 0) {return Result.fail("更新博客出现问题",false);}return Result.success(true);}@RequestMapping("/delete")public Result delete(Integer blogId) {log.info("接收到删除博客请求, blogId: {}",blogId);// 参数校验if (blogId == null) {return Result.fail("参数存在问题",false);}// 业务处理Integer ret = blogInfoService.delete(blogId);if (ret == null || ret < 0) {return Result.fail("删除博客出现问题",false);}return Result.success(true);}
Service 层
public Integer update(BlogInfo blogInfo) {try {return blogInfoMapper.update(blogInfo);} catch (Exception e) {log.error("更新 blogInfo: {} 失败, e: {}", blogInfo, e);}return null;}public Integer delete(Integer blogId) {try {return blogInfoMapper.delete(blogId);} catch (Exception e) {log.error("删除 blogId: {} 失败, e: {}", blogId, e);}return null;}
实现客⼾端代码
- 判断是否显⽰[编辑] [删除]按钮
编辑博客逻辑:
修改blog_update.html⻚⾯加载时,
请求博客详情
已经在getBlogInfo进⾏markdown编辑器的渲染了, 所以把以下代码删除
完善发表博客的逻辑
3.10 加密/加盐
加密介绍
在MySQL数据库中, 我们常常需要对密码, ⾝份证号, ⼿机号等敏感信息进⾏加密, 以保证数据的安全性.如果使⽤明⽂存储, 当⿊客⼊侵了数据库时, 就可以轻松获取到⽤⼾的相关信息, 从⽽对⽤⼾或者企业造成信息泄漏或者财产损失.
⽬前我们⽤⼾的密码还是明⽂设置的, 为了保护⽤⼾的密码信息, 我们需要对密码进⾏加密
密码算法分类
密码算法主要分为三类: 对称密码算法, ⾮对称密码算法, 摘要算法
- 对称密码算法 是指加密秘钥和解密秘钥相同的密码算法. 常⻅的对称密码算法有: AES, DES, 3DES,RC4, RC5, RC6 等.
- ⾮对称密码算法 是指加密秘钥和解密秘钥不同的密码算法. 该算法使⽤⼀个秘钥进⾏加密, ⽤另外⼀个秘钥进⾏解密.
- 加密秘钥可以公开,⼜称为 公钥
- 解密秘钥必须保密,⼜称为 私钥
常⻅的⾮对称密码算法有: RSA, DSA, ECDSA, ECC 等
- 摘要算法 是指把任意⻓度的输⼊消息数据转化为固定⻓度的输出数据的⼀种密码算法. 摘要算法是不可逆的, 也就是⽆法解密. 通常⽤来检验数据的完整性的重要技术, 即对数据进⾏哈希计算然后⽐较摘要值, 判断是否⼀致. 常⻅的摘要算法有: MD5, SHA系列(SHA1, SHA2等), CRC(CRC8, CRC16,CRC32)
加密思路
博客系统中, 我们采⽤MD5算法来进⾏加密.
问题: 虽然经过MD5加密后的密⽂⽆法解密, 但由于相同的密码经过MD5哈希之后的密⽂是相同的, 当存储⽤⼾密码的数据库泄露后, 攻击者会很容易便能找到相同密码的⽤⼾, 从⽽降低了破解密码的难度. 因此, 在对⽤⼾密码进⾏加密时,需要考虑对密码进⾏包装, 即使是相同的密码, 也保存为不同的密⽂. 即使⽤⼾输⼊的是弱密码, 也考虑进⾏增强, 从⽽增加密码被攻破的难度.
解决⽅案: 采⽤为⼀个密码拼接⼀个随机字符来进⾏加密, 这个随机字符我们称之为"盐". 假如有⼀个加盐后的加密串,⿊客通过⼀定⼿段这个加密串, 他拿到的明⽂并不是我们加密前的字符串, ⽽是加密前的字符串和盐组合的字符串, 这样相对来说⼜增加了字符串的安全性.
解密流程: MD5是不可逆的, 通常采⽤"判断哈希值是否⼀致"来判断密码是否正确.
如果⽤⼾输⼊的密码, 和盐值⼀起拼接后的字符串经过加密算法, 得到的密⽂相同, 我们就认为密码正确(密⽂相同, 盐值相同, 推测明⽂相同)
写加密/解密⼯具类
package cn.edu.zxj.springblog.utils;import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;import java.util.UUID;/*** Created with IntelliJ IDEA.* Description:加密解密类 -- 使用 md5** @author: zxj* @date: 2024-02-05* @time: 13:55:38*/
public class SecurityUtil {/*** @description: 对密码进⾏加密**/public static String encrypt(String password) {// 每次⽣成内容不同的,但⻓度固定 32 位的盐值String salt = UUID.randomUUID().toString().replace("-", "");// 最终密码=md5(盐值+原始密码)String finalPassword = DigestUtils.md5DigestAsHex((salt + password).getBytes());// 数据库中存储 盐 + 最终密码的值, 总长度为 64 = 32(salt) + 32(finalPassword)return salt + finalPassword;}/*** 密码验证** @param password 待验证密码* @param finalPassword 最终正确的密码(数据库中加盐的密码)* @return*/public static boolean verify(String password, String finalPassword) {// 非空校验if (!StringUtils.hasLength(password)|| !StringUtils.hasLength(finalPassword)) {return false;}//最终密码不是64位, 则不正确if (finalPassword.length() != 64) {return false;}// 盐值String salt = finalPassword.substring(0,32);// 使⽤盐值+待确认的密码⽣成⼀个最终密码String securityPassword = DigestUtils.md5DigestAsHex((salt + password).getBytes());// 使⽤盐值+最终的密码和数据库的真实密码进⾏对⽐return (salt + securityPassword).equals(finalPassword);}public static void main(String[] args) {String finalPassword = encrypt("123456");System.out.println(finalPassword);System.out.println(verify("1223456",finalPassword));}}
修改⼀下数据库密码
使⽤测试类给密码123456⽣成密⽂:
e2377426880545d287b97ee294fc30ea6d6f289424b95a2b2d7f8971216e39b7
修改数据库明⽂密码为密⽂, 执⾏SQL
修改登录接⼝
源代码gitee链接