赞
踩
结合前面学习的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 相关内容
<?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 { @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, 删除之前写死的博客内容(即 <div class=“blog”> ), 并新增 js 代码处理ajax 请求.
此时⻚⾯的⽇期显⽰为时间戳, 我们从后端也⽇期进⾏处理
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是编码⽅式,⽽不是加密⽅式
<!-- 添加 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>
⽣成令牌
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
引⼊common.js
blog_list.html 代码修改
blog_detail.html 代码修改
前端直接清除掉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的格式符号, 我们对⻚⾯进⾏也下处理
2. 修改博客正⽂内容的显⽰
进⼊⽤⼾详情⻚时, 如果当前登陆⽤⼾正是⽂章作者, 则在导航栏中显⽰ [编辑] [删除] 按钮, ⽤⼾点击时则进⾏相应处理.
需要实现两件事:
删除采⽤逻辑删除, 所以和编辑其实为同⼀个接⼝
约定前后端交互接⼝
修改之前的 获取博客 信息的接⼝, 在响应中加上⼀个字段.
实现服务器代码
其他代码不变. 只处理 “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
修改登录接⼝
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。