结合前面学习的JavaEE 与 Spring 基础, 从 0 到 1 实现一个博客系统, 练习前面学习的知识!
使⽤SSM框架实现⼀个简单的博客系统
共5个⻚⾯
功能描述:
⽤⼾登录成功后, 可以查看所有⼈的博客. 点击 <<查看全⽂>> 可以查看该博客的正⽂内容. 如果该博客作者为当前登录⽤⼾, 可以完成博客的修改和删除操作, 以及发表新博客
⻚⾯预览
⽤⼾登录
博客列表⻚
博客详情⻚
博客发表/修改⻚
建表SQL
-- 建表SQL
CREATE DATABASE
IF NOT EXISTS java_blog_spring charset utf8mb4;
USE java_blog_spring;
-- ⽤⼾表
DROP TABLE
IF 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 = INNODB
DEFAULT CHARACTER
SET = utf8mb4 COMMENT = '⽤⼾表';
-- 博客表
DROP TABLE
IF 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 = INNODB
DEFAULT 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);
创建SpringBoot项⽬, 添加Spring MVC 和MyBatis对应依赖
前端页面代码提取仓库
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/trans_test?characterEncoding=utf8&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
configuration: # 配置打印 MyBatis日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true #配置驼峰自动转换
# 配置 mybatis xml 的文件路径,在 resources/mapper 创建所有表的 xml 文件
mapper-locations: classpath:mapper/**Mapper.xml
测试程序启动后, 是否可以正常访问前端页面
http://127.0.0.1:8080/blog_login.html
前端⻚⾯可以正确显⽰, 说明项⽬初始化成功.
项⽬分为控制层(Controller), 服务层(Service), 持久层(Mapper). 各层之间的调⽤关系如下:
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;
}
定义业务状态码
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 {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
// 启用统一结果返回
return true;
}
@SneakyThrows
@Override
public 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 {
@ExceptionHandler
public Result exceptionAdvice(Exception e) {
log.error("发生错误, e: {}", e);
return Result.fail("内部发生错误, 请联系管理员");
}
}
根据需求, 先⼤致计算有哪些DB相关操作, 完成持久层初步代码, 后续再根据业务需求进⾏完善
依据上述分析的数据操作写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 相关内容
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 {
@Autowired
private BlogInfoMapper blogInfoMapper;
@Test
void selectAll() {
System.out.println(blogInfoMapper.selectAll());
}
@Test
void selectById() {
System.out.println(blogInfoMapper.selectById(1));
}
@Test
void delete() {
System.out.println(blogInfoMapper.delete(3));
}
@Test
void update() {
BlogInfo blogInfo = new BlogInfo();
blogInfo.setTitle("测试添加博客111111111000");
blogInfo.setContent("测试内容222000");
blogInfo.setId(3);
blogInfoMapper.update(blogInfo);
}
@Test
void 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 {
@Autowired
private UserInfoMapper userInfoMapper;
@Test
void selectByName() {
UserInfo userInfo = userInfoMapper.selectByName("zhangsan");
System.out.println(userInfo);
}
@Test
void selectById() {
System.out.println(userInfoMapper.selectById(6));
}
}
约定前后端交互接⼝
客⼾端给服务器发送⼀个 /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, 删除之前写死的博客内容(即
此时⻚⾯的⽇期显⽰为时间戳, 我们从后端也⽇期进⾏处理
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 访问服务器, 验证效果
⽬前点击博客列表⻚的 “查看全⽂” , 能进⼊博客详情⻚, 但是这个博客详情⻚是写死的内容. 我们期望能够根据当前的 博客 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
分析
传统思路:
问题:
集群环境下⽆法直接使⽤Session.
原因分析:
我们开发的项⽬, 在企业中很少会部署在⼀台机器上, 容易发⽣单点故障. (单点故障: ⼀旦这台服务器挂了, 整个应⽤都没法访问了). 所以通常情况下, ⼀个Web应⽤会部署在多个服务器上, 通过Nginx等进⾏负载均衡. 此时, 来⾃⼀个⽤⼾的请求就会被分发到不同的服务器上.
假如我们使⽤Session进⾏会话跟踪, 我们来思考如下场景:
接下来我们介绍第三种⽅案: 令牌技术
令牌其实就是⼀个⽤⼾⾝份的标识, 名称起的很⾼⼤上, 其实本质就是⼀个字符串.
⽐如我们出⾏在外, 会带着⾃⼰的⾝份证, 需要验证⾝份时, 就掏出⾝份证⾝份证不能伪造, 可以辨别真假.
服务器具备⽣成令牌和验证令牌的能⼒
我们使⽤令牌技术, 继续思考上述场景:
令牌的优缺点
优点:
当前企业开发中, 解决会话跟踪使⽤最多的⽅案就是令牌技术.
令牌本质就是⼀个字符串, 他的实现⽅式有很多, 我们采⽤⼀个JWT令牌来实现.
JWT全称: JSON Web Token
官⽹: https://jwt.io/
JWT组成
JWT由三部分组成, 每部分中间使⽤点 (.) 分隔,⽐如:aaaaa.bbbbb.cccc
此部分不建议存放敏感信息, 因为此部分可以解码还原原始内容.
防⽌被篡改, ⽽不是防⽌被解析.
JWT之所以安全, 就是因为最后的签名. jwt当中任何⼀个字符被篡改, 整个令牌都会校验失败.
就好⽐我们的⾝份证, 之所以能标识⼀个⼈的⾝份, 是因为他不能被篡改, ⽽不是因为内容加密.(任何⼈都可以看到⾝份证的信息, jwt 也是)
对上⾯部分的信息, 使⽤Base64Url 进⾏编码, 合并在⼀起就是jwt令牌
Base64是编码⽅式,⽽不是加密⽅式
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-apiartifactId>
<version>0.11.5version>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-implartifactId>
<version>0.11.5version>
<scope>runtimescope>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-jacksonartifactId>
<version>0.11.5version>
<scope>runtimescope>
dependency>
⽣成令牌
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: 生成令牌
**/
@Test
public 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: 生成令牌
**/
@Test
public 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: 生成密钥
**/
@Test
public void genKey() {
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
String secretStr= Encoders.BASE64.encode(key.getEncoded());
// 利用上述得到安全复杂的 secretString
System.out.println(secretStr);
}
public static void main(String[] args) {
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
String secretStr= Encoders.BASE64.encode(key.getEncoded());
// 利用上述得到安全复杂的 secretString
System.out.println(secretStr);
}
}
运行程序, 就可以生成 token
校验令牌
完成了令牌的⽣成, 我们需要根据令牌, 来校验令牌的合法性(以防客⼾端伪造)
/**
* @description: 验证 token 的合法性, 解析 token
**/
@Test
public void parseJWT() {
String token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiemhhbmdzYW4iLCJpZCI6MSwiaWF0IjoxNzA3MDU3Njk2LCJleHAiOjE3MDcwNTk0OTZ9.4xXmir0P5cBGnS0z-fT39MzuhY9ACV8Hjt2yF5Mtgp4";
// 创建解析器, 设置签名密钥
JwtParserBuilder jwtParserBuilder = Jwts.parserBuilder().setSigningKey(KEY);
// 解析token
Claims claims = jwtParserBuilder.build().parseClaimsJws(token).getBody();
System.out.println(claims);
}
令牌解析后, 我们可以看到⾥⾯存储的信息,如果在解析的过程当中没有报错,就说明解析成功了.
令牌解析时, 也会进⾏时间有效性的校验, 如果令牌过期了, 解析也会失败.
修改令牌中的任何⼀个字符, 都会校验失败, 所以令牌⽆法篡改
学习令牌的使⽤之后, 接下来我们通过令牌来完成⽤⼾的登录
创建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 {
// 解析token
claims = 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());
// 利用上述得到安全复杂的 secretString
System.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");
部署程序, 验证效果.
当⽤⼾访问 博客列表⻚ 和 博客详情⻚ 时, 如果⽤⼾当前尚未登陆, 就⾃动跳转到登陆⻚⾯.
我们可以采⽤拦截器来完成, 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 {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("正在进行登录拦截校验...");
// 1. 从 header 中获得 token
String token = request.getHeader("user_token");
log.info("从 request 中获得 token: {}",token);
// 2. 验证 token
Claims 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/**"
);
@Autowired
LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(excludePath);
}
}
实现客⼾端代码
$(document).ajaxSend(function (e,xhr,opt) {
var user_token = localStorage.getItem("user_token");
xhr.setRequestHeader("user_token",user_token);
});
ajaxSend() ⽅法在 AJAX 请求开始时执⾏函数
error: function (error) {
console.log(error);
if (error != null && error.status == 401) {
alert("用户未登录, 即将跳转到登录界面");
// 已经被拦截器拦截了, 未登录
location.href = "blog_login.html";
}
}
⽬前⻚⾯的⽤⼾信息部分是写死的. 形如:
注意: 当前我们只是实现了显⽰⽤⼾名, 没有实现显⽰⽤⼾的头像以及⽂章数量等信息.
约定前后端交互接⼝
在博客列表⻚, 获取当前登陆的⽤⼾的⽤⼾信息.
在博客详情⻚, 获取当前⽂章作者的⽤⼾信息
在 UserController添加代码
@RequestMapping("/getUserInfo")
public Result getUserInfo(HttpServletRequest request) {
log.info("收到获取用户登录信息请求...");
// 1. 提取token中的用户ID
String 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;
}
实现客⼾端代码
修改⽅式同上
代码整合: 提取common.js
前端直接清除掉token即可.
实现客⼾端代码
<<注销>> 链接已经提前添加了onclick事件
在common.js中完善logout⽅法
约定前后端交互接⼝
修改 BlogController, 新增 add ⽅法.
@RequestMapping("/add")
public Boolean add(BlogInfo blogInfo, HttpServletRequest request) {
log.info("接收到添加博客信息请求, blogInfo: {}",blogInfo);
// 参数校验
if (blogInfo == null) {
return false;
}
// 1. 获取 token 中 UserId
String 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 是⼀个开源的⻚⾯ markdown 编辑器组件.
官⽹参⻅: http://editor.md.ipandao.com/
代码: https://pandao.github.io/editor.md/
实现客⼾端代码
修改 blog_edit.html
此时会发现详情⻚会显⽰markdown的格式符号, 我们对⻚⾯进⾏也下处理
进⼊⽤⼾详情⻚时, 如果当前登陆⽤⼾正是⽂章作者, 则在导航栏中显⽰ [编辑] [删除] 按钮, ⽤⼾点击时则进⾏相应处理.
需要实现两件事:
删除采⽤逻辑删除, 所以和编辑其实为同⼀个接⼝
约定前后端交互接⼝
修改之前的 获取博客 信息的接⼝, 在响应中加上⼀个字段.
实现服务器代码
其他代码不变. 只处理 “getBlogDeatail” 中的逻辑.
@RequestMapping("/getBlogDetail")
public BlogInfo getBlogDetail(Integer blogId, HttpServletRequest request) {
log.info("接收到获取博客详细信息请求, blogId: {}",blogId);
// 1. 参数校验
if (blogId < 1) {
return null;
}
// 2. 获取当前登录的Id
String 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;
}
增加 更新删除 功能
@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编辑器的渲染了, 所以把以下代码删除
完善发表博客的逻辑
加密介绍
在MySQL数据库中, 我们常常需要对密码, ⾝份证号, ⼿机号等敏感信息进⾏加密, 以保证数据的安全性.如果使⽤明⽂存储, 当⿊客⼊侵了数据库时, 就可以轻松获取到⽤⼾的相关信息, 从⽽对⽤⼾或者企业造成信息泄漏或者财产损失.
⽬前我们⽤⼾的密码还是明⽂设置的, 为了保护⽤⼾的密码信息, 我们需要对密码进⾏加密
密码算法分类
密码算法主要分为三类: 对称密码算法, ⾮对称密码算法, 摘要算法
常⻅的⾮对称密码算法有: RSA, DSA, ECDSA, ECC 等
加密思路
博客系统中, 我们采⽤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链接