用我的指尖实现我的技术梦
基于Shiro | JWT整合WxJava实现微信小程序登录

目录

  1. 1. 前言
  2. 2. 特性
  3. 3. 准备工作
  4. 4. 整体思路
  5. 5. token加密说明
  6. 6. token校验说明
  7. 7. 项目实现
    1. 7.1. 创建Maven项目
    2. 7.2. 相关配置 | 工具准备
      1. 7.2.1. 配置fastJson
      2. 7.2.2. 配置Redis
      3. 7.2.3. 配置RestTemplate
      4. 7.2.4. 返回集封装
      5. 7.2.5. 异常封装与处理
    3. 7.3. 准备数据源
    4. 7.4. 构建JWT
    5. 7.5. Realm配置
    6. 7.6. 重写filter
    7. 7.7. Shiro核心配置
    8. 7.8. 验证
  8. 8. 最后

掘金原文体验更加👉👉戳这里

前言

最近在做毕业设计,涉及到微信小程序的开发,要求前端小程序用户使用微信身份登录,登陆成功后,后台返回自定义登录状态token给小程序,后续小程序发送API请求都需要携带token才能访问后台数据。

本文是对接微信小程序,实现自定义登录状态的一个完整示例,实现了小程序的自定义登陆,将自定义登陆态token返回给小程序作为登陆凭证。用户的信息保存在数据库中,登陆态token缓存在redis中。涉及的技术栈:

  • SpringBoot -> 后端基础环境
  • Shiro -> 安全框架
  • JWT -> 加密token
  • MySQL -> 主库,存储业务数据
  • MyBatis-Plus -> 操作数据库
  • Redis -> 缓存token和其他热点数据
  • Lombok -> 简化开发
  • FastJson -> json消息处理
  • RestTemplate -> 优雅的处理web请求

项目GitHub地址:https://github.com/gongsir0630/shiro-jwt-demo

特性

  • 基于WxJava对接微信小程序,实现用户登录、消息处理
  • 支持Shiro注解编程,保持高度的灵活性
  • 使用JWT进行校验,完全实现无状态鉴权
  • 使用Redis存储自定义登陆态token,支持过期时间
  • 支持跨域请求

准备工作

基础知识预备:

  • 具备SpringBoot基础知识并且会使用基本注解;
  • 了解JWT(Json Web Token)的基本概念,并且会简单操作JWT的 JAVA SDK
  • 了解Shiro的基本概念:Subject、Realm、SecurityManager等(建议去官网学习一下)

其他说明:

本文只对shiro和jwt整合进行介绍说明,具体的微信登录实现是使用RestTemplate调用我自己的wx-java-miniapp项目,该项目基于WxJava实现,支持多个小程序登录、消息处理。

本文使用以下调用处理即可:

1
2
3
4
5
6
7
8
9
10
11
12
// 1. todo: 微信登录: code + appid -> openId + session_key
// appid: 从配置文件读取
MultiValueMap<String, Object> request = new LinkedMultiValueMap<>();
// 参数封装, 微信登录需要以下参数
request.add("code", code);
// eg: http://localhost:8081/wx/user/{appid}/login
String path = url+"/user/"+appid+"/login";
// 请求
JSONObject dto = restTemplate.postForObject(path, request, JSONObject.class);
log.info("--->>>来自[{}]的返回 = [{}]",path,dto);

// 2. todo: 使用openId和session_key生成自定义登录状态 -> token

项目地址:

整体思路

先了解一下小程序官方登录流程,官方说明戳这里

小程序登录流程

  1. 小程序调用wx.login()得到code,将code发送到后台,后台通过wx-java-miniapp获取到用户的openIdsession_key
  2. 后台通过jwt工具生成自定义用户状态信息token,并且后台在数据库中查询openId判断是否存在,根据查询结果封装不同的消息,最后连同token一起返回给小程序;
  3. 之后用户访问每一个需要权限的API请求必须在header中添加Authorization字段,后台会进行token的校验,如果有误会直接返回401

token加密说明

  • 使用uuid随机生成一个jwt-id
  • 将用户的openIdsession_key连同jwt-id一起,使用小程序的appid进行签名加密并设置过期时间,最终生成token
  • "JWT-SESSION-"+jwt-idtoken以key-value的形式存入redis中,并设置相同的过期时间

token校验说明

  • 解析token中jwt-id
  • "JWT-SESSION-"+jwt-id为key从redis中获取redisToken
  • 解析redisToken的携带信息,重新以相同的方式生成验证器,同token进行校验比对

项目实现

  • 项目数据库使用MySQL作为作为主库,如果是clone的项目,请在运行之前准备好相应的数据库,并修改配置信息。
  • 项目使用了redis缓存,运行前请在本地安装redis,使用默认配置即可,无需修改。
  • 项目中使用了lombok简化开发,请在idea或者eclipse安装lombok插件。

创建Maven项目

新建一个SpringBoot项目,修改pom文件,添加相关dependency:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.github.gongsir0630</groupId>
<artifactId>shiro-jwt-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>shiro-jwt-demo</name>
<description>Demo project for Spring Boot</description>

<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.3.7.RELEASE</spring-boot.version>
</properties>

<dependencies>
<!-- shiro: 用户认证\接口鉴权 -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
</dependency>
<!-- jwt: token认证 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.1</version>
</dependency>
<!-- redis: 数据缓存 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 引入fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- druid数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
<!-- mybatis-plus: 操作数据库 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- mysql驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- 工具: 简化model开发 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 单元测试工具 -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.3.7.RELEASE</version>
<configuration>
<mainClass>com.github.gongsir0630.shirodemo.ShiroJwtDemoApplication</mainClass>
</configuration>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

</project>

注意JDK版本:1.8

相关配置 | 工具准备

配置你的application.yml ,主要是配置你的小程序appid和url,还有你的数据库和redis。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 设置日志级别
logging:
level:
org.springframework.web: info
com.github.gongsir0630.shirodemo: debug
# dev环境配置文件
spring:
# 数据库相关配置信息: 无需再本地安装mysql,使用yzhelp.top云端数据库
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/shiro-jwt-demo
username: username
password: password
# redis 配置信息: 在本地安装redis
redis:
host: 127.0.0.1
port: 6379
database: 0

---
# 服务启动的端口号
server:
port: 8080

---
# 微信小程序配置 appid / url
wx:
# 小程序AppId
appid: appid
# 自研小程序接口调用地址
url: http://localhost:8081/wx

说明:
appid: 当前小程序的appid
url: wx-java-miniapp项目接口地址

配置fastJson

在启动类中配置fastJson -> ShiroJwtDemoApplication.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* @author gongsir <a href="https://github.com/gongsir0630">码之泪殇</a>
* 描述: Spring Boot 工程启动类,可以直接点击下面的main方法运行程序
*/

@SpringBootApplication
public class ShiroJwtDemoApplication {

public static void main(String[] args) {
SpringApplication.run(ShiroJwtDemoApplication.class, args);
}

/**
* fastjson 配置注入: 使用阿里巴巴的 fastjson 处理 json 信息
* @return HttpMessageConverters
*/
@Bean
public HttpMessageConverters fastJsonHttpMessageConverters() {
// 消息转换对象
FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter();
// fastjson 配置
FastJsonConfig config = new FastJsonConfig();
config.setSerializerFeatures(SerializerFeature.PrettyFormat);
config.setDateFormat("yyyy-MM-dd");
// 配置注入消息转换器
converter.setFastJsonConfig(config);
// 让 spring 使用自定义的消息转换器
return new HttpMessageConverters(converter);
}
}

配置Redis

配置Redis -> RedisConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 14:15
* 你的指尖,拥有改变世界的力量
* 描述: Redis配置
* EnableCaching: 开启缓存
*/
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
return RedisCacheManager.create(factory);
}
}

配置RestTemplate

配置RestTemplate -> RestTemplateConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 14:10
* 你的指尖,拥有改变世界的力量
* 描述: RestTemplate的配置类
*/
@Configuration
public class RestTemplateConfig {

@Bean
public RestTemplate restTemplate(ClientHttpRequestFactory factory) {
return new RestTemplate(factory);
}

@Bean
public ClientHttpRequestFactory simpleClientHttpRequestFactory() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
// 连接超时时间设置为10秒
factory.setConnectTimeout(1000 * 10);
// 读取超时时间为单位为60秒
factory.setReadTimeout(1000 * 60);
return factory;
}
}

返回集封装

CodeMsg.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 20:17
* 你的指尖,拥有改变世界的力量
* 描述: code和msg封装
*/
public class CodeMsg {
private final int code;
private final String msg;

public static CodeMsg SUCCESS=new CodeMsg(0,"success");

public static CodeMsg LOGIN_FAIL = new CodeMsg(-1,"code2session failure, please try aging");

public static CodeMsg NO_USER = new CodeMsg(1000,"user not found");
public static CodeMsg SESSION_KEY_ERROR = new CodeMsg(1001,"sessionKey is invalid");
public static CodeMsg TOKEN_ERROR = new CodeMsg(1002,"token is invalid");
public static CodeMsg SHIRO_ERROR = new CodeMsg(1003,"token is invalid");

public CodeMsg(int code, String msg) {
this.code=code;
this.msg=msg;
}

public int getCode() {
return code;
}

public String getMsg() {
return msg;
}

@Override
public String toString() {
return "CodeMsg{" +
"code=" + code +
", msg='" + msg + '\'' +
'}';
}

}

Result.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 18:45
* 你的指尖,拥有改变世界的力量
* 描述:
* 输出结果的封装
* 只要get不要set,进行更好的封装
* @param <T> data泛型
*/
public class Result<T> {

private int code;
private String msg;
private T data;


private Result(T data){
this.code=0;
this.msg="success";
this.data=data;
}

private Result(CodeMsg mg, T data) {
if (mg==null){
return;
}
this.code=mg.getCode();
this.msg=mg.getMsg();
this.data=data;
}


/**
* 成功时
* @param <T> data泛型
* @return Result
*/
public static <T> Result<T> success(T data){
return new Result<T>(data);
}

/**
* 失败
* @param <T> data泛型
* @return Result
*/
public static <T> Result<T> fail(CodeMsg mg, T data){
return new Result<T>(mg,data);
}

public int getCode() {
return code;
}


public String getMsg() {
return msg;
}


public T getData() {
return data;
}
}

异常封装与处理

自定义异常 -> ApiAuthException.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import com.github.gongsir0630.shirodemo.controller.res.CodeMsg;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 20:24
* 你的指尖,拥有改变世界的力量
* 描述: 自定义异常, 用于处理Api认证失败异常信息保存
*/
public class ApiAuthException extends RuntimeException {
private CodeMsg codeMsg;

public ApiAuthException() {
super();
}

public ApiAuthException(CodeMsg codeMsg) {
super(codeMsg.getMsg());
this.codeMsg = codeMsg;
}

public CodeMsg getCodeMsg() {
return codeMsg;
}

public void setCodeMsg(CodeMsg codeMsg) {
this.codeMsg = codeMsg;
}
}

全局异常处理 -> AppExceptionHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 15:49
* 你的指尖,拥有改变世界的力量
* 描述: 全局异常处理
*/
@RestControllerAdvice
@Slf4j
public class AppExceptionHandler {

/**
* 处理 Shiro 异常
* @param e 异常信息
* @return json
*/
@ExceptionHandler({ShiroException.class})
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ResponseEntity<Result<JSONObject>> handShiroException(ShiroException e) {
log.error("--->>> 捕捉到 [ApiAuthException] 异常: {}", e.getMessage());
return new ResponseEntity<>(Result.fail(CodeMsg.SHIRO_ERROR,null), HttpStatus.UNAUTHORIZED);
}

/**
* 处理 自定义ApiAuthException异常
* @param e 异常信息
* @return json
*/
@ExceptionHandler({ApiAuthException.class})
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ResponseEntity<Result<JSONObject>> handApiAuthException(ApiAuthException e) {
log.error("--->>> 捕捉到 [ApiAuthException] 异常: {},{}",e.getCodeMsg().getCode(),e.getCodeMsg().getMsg() );
return new ResponseEntity<>(Result.fail(e.getCodeMsg(),null), HttpStatus.UNAUTHORIZED);
}
}

准备数据源

  • 数据库:shiro-jwt-demo
  • 数据表:user
    示例数据库表结构

注意:这里是业务数据库,也就是我们小程序用户信息都由我们自己存储,第一次默认使用微信公开信息注册,之后用户可以自行更新这些信息,和微信信息独立开。

创建对应的实体类 -> User.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/21 23:44
* 你的指尖,拥有改变世界的力量
* 描述: 业务用户信息
*/
@Data
@TableName("user")
public class User {
/**
* 主键,数据库字段为user_id -> userId == openId
*/
@TableId(value = "user_id",type = IdType.INPUT)
private String userId;
private String name;
private String photo;
private String sex;
private String grade;
private String college;
private String contact;
}

使用MyBatis-plus创建mapper接口 -> UserMapper.java

1
2
3
4
5
6
7
8
9
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 19:44
* 你的指尖,拥有改变世界的力量
* 描述: User类mapper接口,继承自BaseMapper(已经实现User的CRUD)
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

MyBatis-Plus配置 -> MybatisPlusConfig.java

1
2
3
4
5
6
7
8
9
10
11
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/19 17:15
* 你的指尖,拥有改变世界的力量
* 描述: MyBatis-Plus插件配置
*/

@Configuration
@MapperScan("com.github.gongsir0630.shirodemo.mapper")
public class MybatisPlusConfig {
}

创建User业务接口,这里仅仅演示login -> UserService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 19:49
* 你的指尖,拥有改变世界的力量
* 描述: 用户接口
*/
public interface UserService extends IService<User> {
/**
* 登录
* @param jsCode 小程序code
* @return 登录信息: 包含token
*/
Map<String, String> login(String jsCode);
}

再创建一个微信登录信息对象,主要用作接收微信的openid和session_key,以及用作shiro认证 -> WxAccount.java

1
2
3
4
5
6
7
8
9
10
11
12
13
 * @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 19:58
* 你的指尖,拥有改变世界的力量
* 描述: 微信认证信息
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class WxAccount {
private String openId;
private String sessionKey;
}

注意:该类不会用于业务信息交互,所以不需要Mapper与db交互。

微信登录接口,在这里实现与微信服务器的信息交互 -> WxAccountService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 * @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 20:06
* 你的指尖,拥有改变世界的力量
* 描述: 微信接口
*/
public interface WxAccountService {
/**
* 微信小程序用户登陆,完整流程可参考下面官方地址,本例中是按此流程开发
* https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
* 1 . 微信小程序端传入code。
* 2 . 通过wx-java-miniapp项目调用微信code2session接口获取openid和session_key
*
* @param code 小程序端 调用 wx.login 获取到的code,用于调用 微信code2session接口
* @return JSONObject: 包含openId和sessionKey
*/
WxAccount login(String code);
}

接口实现逻辑:

  1. 从配置文件读取appidurl
  2. 凭借目标请求地址path,例如登录是 {url}/wx/user/{appid}/login
  3. 参数封装,封装来自小程序的code
  4. 使用RestTemplate发起登录请求;
  5. 处理返回集。

代码实现 -> WxAccountServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 16:12
* 你的指尖,拥有改变世界的力量
* 描述: 微信接口实现: 用 restTemplate 调用 [wxApp] 应用的接口
*/
@Service
@Slf4j
public class WxAccountServiceImpl implements WxAccountService {

@Value("${wx.appid}")
private String appid;
@Value("${wx.url}")
private String url;

@Resource
private RestTemplate restTemplate;

@Override
public WxAccount login(String code) {
// todo: 微信登录: code + appid -> openId + session_key
// appid: 从配置文件读取
MultiValueMap<String, Object> request = new LinkedMultiValueMap<>();
// 参数封装, 微信登录需要以下参数
request.add("code", code);
// eg: http://localhost:8081/wx/user/{appid}/login
String path = url+"/user/"+appid+"/login";
// 请求
JSONObject dto = restTemplate.postForObject(path, request, JSONObject.class);
log.info("--->>>来自[{}]的返回 = [{}]",path,dto);
int errCode = -1;
if (dto != null ) {
errCode = Integer.parseInt(dto.get("code").toString());
} else {
throw new ApiAuthException(CodeMsg.LOGIN_FAIL);
}
if (0 != errCode) {
throw new ApiAuthException(new CodeMsg(Integer.parseInt(dto.get("code").toString()),
dto.get("msg").toString()));
}
// code2session success
JSONObject data = dto.getJSONObject("data");
return JSON.toJavaObject(data, WxAccount.class);
}
}

构建JWT

jwt工具类,用于生成token签名, token校验 -> JwtUtil.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 10:26
* 你的指尖,拥有改变世界的力量
* 描述: jwt工具类: 生成token签名, token校验
*/
@Component
@SuppressWarnings("All")
public class JwtUtil {
/**
* 过期时间: 2小时
*/
private static final long EXPIRE_TIME = 7200;
/**
* 使用 appid 签名
*/
@Value("${wx.appid}")
private String appsecret;

@Autowired
private StringRedisTemplate redisTemplate;

/**
* 根据微信用户登陆信息创建 token
* 使用`uuid`随机生成一个jwt-id
* 将用户的`openId`、`session_key`连同`jwt-id`一起,使用小程序的`appid`进行签名加密并设置过期时间,最终生成`token`
* 将`"JWT-SESSION-"+jwt-id`和`token`以key-value的形式存入`redis`中,并设置相同的过期时间
* 注 : 这里的token会被缓存到redis中,用作为二次验证
* redis里面缓存的时间应该和jwt token的过期时间设置相同
*
* @param wxAccount 微信用户信息
* @return 返回 jwt token
*/
public String sign(WxAccount account) {
//JWT 随机ID,做为redis验证的key
String jwtId = UUID.randomUUID().toString();
//1 . 加密算法进行签名得到token
Algorithm algorithm = Algorithm.HMAC256(appsecret);
String token = JWT.create()
.withClaim("openId", account.getOpenId())
.withClaim("sessionKey", account.getSessionKey())
.withClaim("jwt-id",jwtId)
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE_TIME * 1000))
.sign(algorithm);
//2 . Redis缓存JWT, 注 : 请和JWT过期时间一致
redisTemplate.opsForValue().set("JWT-SESSION-"+jwtId, token, EXPIRE_TIME, TimeUnit.SECONDS);
return token;
}

/**
* token 检验
* @param token
* @return bool
*/
public boolean verify(String token) {
try {
//1 . 根据token解密,解密出jwt-id , 先从redis中查找出redisToken,匹配是否相同
String redisToken = redisTemplate.opsForValue().get("JWT-SESSION-" + getClaimsByToken(token).get("jwt-id").asString());
if (!token.equals(redisToken)) {
return Boolean.FALSE;
}
//2 . 得到算法相同的JWTVerifier
Algorithm algorithm = Algorithm.HMAC256(appsecret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("openId", getClaimsByToken(redisToken).get("openId").asString())
.withClaim("sessionKey", getClaimsByToken(redisToken).get("sessionKey").asString())
.withClaim("jwt-id",getClaimsByToken(redisToken).get("jwt-id").asString())
.build();
//3 . 验证token
verifier.verify(token);
//4 . Redis缓存JWT续期
redisTemplate.opsForValue().set("JWT-SESSION-" + getClaimsByToken(token).get("jwt-id").asString(),
redisToken,
EXPIRE_TIME,
TimeUnit.SECONDS);
return Boolean.TRUE;
} catch (Exception e) {
//捕捉到任何异常都视为校验失败
return Boolean.FALSE;
}
}

/**
* 从token解密信息
* @param token token
* @return
* @throws JWTDecodeException
*/
public Map<String, Claim> getClaimsByToken(String token) throws JWTDecodeException {
return JWT.decode(token).getClaims();
}
}

Realm配置

创建JwtToken,用于shiro鉴权,需要实现AuthenticationToken -> JwtToken.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 10:48
* 你的指尖,拥有改变世界的力量
* 描述: 鉴权用的token,需要实现 AuthenticationToken
*/
@Data
@AllArgsConstructor
public class JwtToken implements AuthenticationToken {
private String token;
@Override
public Object getPrincipal() {
return token;
}

@Override
public Object getCredentials() {
return token;
}
}

自定义Shiro的Realm配置,需要在Realm中实现我们自定义的登陆及授权逻辑 -> ShiroRealm.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import com.github.gongsir0630.shirodemo.controller.res.CodeMsg;
import com.github.gongsir0630.shirodemo.exception.ApiAuthException;
import com.github.gongsir0630.shirodemo.wx.util.JwtUtil;
import com.github.gongsir0630.shirodemo.wx.vo.JwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authc.credential.CredentialsMatcher;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 15:28
* 你的指尖,拥有改变世界的力量
* 描述: Realm 的一个配置管理类 allRealm()方法得到所有的realm
*/
@Component
@Slf4j
public class ShiroRealm {
@Resource
private JwtUtil jwtUtil;

/**
* 封装所有自定义的realm规则链 -> shiro配置中会将规则注入到shiro的securityManager
* @return 所有自定义的realm规则
*/
public List<Realm> allRealm() {
List<Realm> realmList = new LinkedList<>();
realmList.add(authorizingRealm());
return Collections.unmodifiableList(realmList);
}

/**
* 自定义 JWT的 Realm
* 重写 Realm 的 supports() 方法是通过 JWT 进行登录判断的关键
*/
private AuthorizingRealm authorizingRealm() {
AuthorizingRealm realm = new AuthorizingRealm() {
/**
* 当需要检测 用户权限 时调用此方法,例如checkRole,checkPermission之类的
* 根据业务需求自行编写验证逻辑
* @param principalCollection == token
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String token = principalCollection.toString();
log.info("--->>>PrincipalCollection: [{}]",token);
// todo: 自定义权限验证, 比如role和permission验证
return new SimpleAuthorizationInfo();
}

/**
* 默认使用此方法进行用户名正确与否校验: 验证token逻辑
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String jwtToken = (String) authenticationToken.getCredentials();
String openId = jwtUtil.getClaimsByToken(jwtToken).get("openId").asString();
String sessionKey = jwtUtil.getClaimsByToken(jwtToken).get("sessionKey").asString();
if (null == openId || "".equals(openId)) {
throw new ApiAuthException(CodeMsg.NO_USER);
}
if (null == sessionKey || "".equals(sessionKey)) {
throw new ApiAuthException(CodeMsg.SESSION_KEY_ERROR);
}
if (!jwtUtil.verify(jwtToken)) {
throw new ApiAuthException(CodeMsg.TOKEN_ERROR);
}
// 将 openId 和 sessionKey 装配到subject中
// 在 Controller 中使用 SecurityUtils.getSubject().getPrincipal() 即可获取用户openId
return new SimpleAuthenticationInfo(openId,sessionKey,this.getClass().getName());
}

/**
* 注意坑点 : 必须重写此方法,不然Shiro会报错
* 因为创建了 JWTToken 用于替换Shiro原生 token,所以必须在此方法中显式的进行替换,否则在进行判断时会一直失败
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
};
realm.setCredentialsMatcher(credentialsMatcher());
return realm;
}

/**
* 注意 : 密码校验, 这里因为是JWT形式,就无需密码校验和加密,直接让其返回为true(如果不设置的话,该值默认为false,即始终验证不通过)
*/
private CredentialsMatcher credentialsMatcher() {
// 实现boolean doCredentialsMatch(AuthenticationToken var1, AuthenticationInfo var2);
return (authenticationToken, authenticationInfo) -> true;
}
}

重写filter

所有的请求都会先经过Filter,所以我们继承官方的BasicHttpAuthenticationFilter,并且重写方法即可 -> JwtFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import com.github.gongsir0630.shirodemo.wx.vo.JwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 10:58
* 你的指尖,拥有改变世界的力量
* 描述: JWT核心过滤器配置
* 所有的请求都会先经过Filter,继承官方的BasicHttpAuthenticationFilter,并且重写鉴权的方法
* 执行流程 preHandle->isAccessAllowed->isLoginAttempt->executeLogin
*/
@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {
/**
* 跨域支持
* @param request 请求
* @param response 相应
* @return bool
* @throws Exception 异常
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}

@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
// 判断request是否包含 Authorization 字段
String auth = getAuthzHeader(request);
return auth != null && !"".equals(auth);
}

@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (isLoginAttempt(request,response)) {
// executeLogin 进入登录逻辑
// 从request请求头获取 Authorization 字段
String token = getAuthzHeader(request);
log.info("--->>>JwtFilter::isAccessAllowed拦截到认证token信息:[{}]",token);
// 这里会提交给刚刚我们自定义的realm处理
getSubject(request,response).login(new JwtToken(token));
}
// 这里返回true表示所有验证结果都能通过, 在controller中可以使用shiro注解限制是否需要登录权限
// 设置true即允许游客访问
// 设置false则必须携带token进行验证
return true;
}
}

Shiro核心配置

核心配置 -> ShiroConfig.java

  • 配置realm规则链
  • 配置访问策略:url和filter
  • 开启shiro注解支持
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import com.github.gongsir0630.shirodemo.filter.JwtFilter;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;

import javax.servlet.Filter;
import java.util.HashMap;
import java.util.Map;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 16:48
* 你的指尖,拥有改变世界的力量
* 描述: shiro核心配置
*/
@Configuration
public class ShiroConfig {
/**
* SecurityManager,安全管理器,所有与安全相关的操作都会与之进行交互;
* 它管理着所有Subject,所有Subject都绑定到SecurityManager,与Subject的所有交互都会委托给SecurityManager
* DefaultWebSecurityManager :
* 会创建默认的DefaultSubjectDAO(它又会默认创建DefaultSessionStorageEvaluator)
* 会默认创建DefaultWebSubjectFactory
* 会默认创建ModularRealmAuthenticator
*/
@Bean
public DefaultWebSecurityManager securityManager(ShiroRealm shiroRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置realms
securityManager.setRealms(shiroRealm.allRealm());
// close session
DefaultSubjectDAO defaultSubjectDAO = (DefaultSubjectDAO) securityManager.getSubjectDAO();
DefaultSessionStorageEvaluator evaluator = (DefaultSessionStorageEvaluator) defaultSubjectDAO.getSessionStorageEvaluator();
evaluator.setSessionStorageEnabled(Boolean.FALSE);
defaultSubjectDAO.setSessionStorageEvaluator(evaluator);
return securityManager;
}

/**
* 配置Shiro的访问策略
*/
@Bean
public ShiroFilterFactoryBean filterFactoryBean(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
Map<String, Filter> filterMap = new HashMap<>(8);
filterMap.put("jwt", new JwtFilter());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);

Map<String, String> filterRuleMap = new HashMap<>(8);
//登陆相关api不需要被过滤器拦截
filterRuleMap.put("/user/login/**", "anon");
// 所有请求通过JWT Filter
filterRuleMap.put("/**", "jwt");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}

/**
* 添加注解支持
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
advisorAutoProxyCreator.setProxyTargetClass(true);
return advisorAutoProxyCreator;
}

/**
* 添加注解依赖
*/
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}

/**
* 开启注解
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}

验证

实现UserService中的login方法 -> UserServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.gongsir0630.shirodemo.mapper.UserMapper;
import com.github.gongsir0630.shirodemo.model.User;
import com.github.gongsir0630.shirodemo.service.UserService;
import com.github.gongsir0630.shirodemo.wx.model.WxAccount;
import com.github.gongsir0630.shirodemo.wx.service.WxAccountService;
import com.github.gongsir0630.shirodemo.wx.util.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 11:19
* 你的指尖,拥有改变世界的力量
* 描述:
*/
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Resource
private UserMapper userMapper;
@Resource
private JwtUtil jwtUtil;
@Resource
private WxAccountService wxAccountService;

@Override
public Map<String, String> login(String jsCode) {
Map<String, String> res = new HashMap<>();
WxAccount wxAccount = wxAccountService.login(jsCode);
log.info("--->>>wxAccount信息:[{}]",wxAccount);
User user = userMapper.selectById(wxAccount.getOpenId());
if (user == null) {
// todo: 用户不存在, 提醒用户提交注册信息
res.put("canLogin",Boolean.FALSE.toString());
} else {
res.put("canLogin",Boolean.TRUE.toString());
}
res.put("token", jwtUtil.sign(wxAccount));
return res;
}
}

创建controller,编写测试api -> UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.github.gongsir0630.shirodemo.controller.res.CodeMsg;
import com.github.gongsir0630.shirodemo.controller.res.Result;
import com.github.gongsir0630.shirodemo.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.Map;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 11:12
* 你的指尖,拥有改变世界的力量
* 描述: 用户信息接口类,包含小程序登录注册
*/
@RestController
@Slf4j
@RequestMapping("user")
public class UserController {
@Resource
private UserService userService;

/**
* 从认证信息中获取用户Id: userId == openId
* @return userId
*/
private String getUserId() {
return SecurityUtils.getSubject().getPrincipal().toString();
}

/**
* 小程序用户登录接口: 通过js_code换取openId, 判断用户是否已经注册
* @param code wx.login() 得到的code凭证
* @return token
*/
@PostMapping("/login")
public ResponseEntity<Result<JSONObject>> login(String code) {
if (StringUtils.isBlank(code)) {
return new ResponseEntity<>(Result.fail(new CodeMsg(401,"code is empty"), null), HttpStatus.OK);
}
log.info("--->接收到来自小程序端的code:[{}]",code);
// todo: 使用 code -> wxAccountService.login() -> openId,session_key
Map<String, String> loginMap = userService.login(code);
boolean canLogin = Boolean.parseBoolean(loginMap.get("canLogin"));
String token = loginMap.get("token");
JSONObject data = new JSONObject();
data.put("token",token);
data.put("canLogin",canLogin);
log.info("--->>>返回认证信息:[{}]", data.toString());
if (!canLogin) {
// todo: 用户不存在,提示用户注册
return new ResponseEntity<>(Result.fail(CodeMsg.NO_USER,data),HttpStatus.OK);
}
return new ResponseEntity<>(Result.success(data),HttpStatus.OK);
}

/**
* 使用 RequiresAuthentication 注解, 需要验证才能访问
* @return userId
*/
@GetMapping("/hello")
@RequiresAuthentication
public ResponseEntity<Result<JSONObject>> requireAuth() {
JSONObject data = new JSONObject();
data.put("hello",getUserId());
return new ResponseEntity<>(Result.success(data),HttpStatus.OK);
}
}

编写小程序测试代码获取code

1
2
3
4
5
6
wx.login({
timeout: 3000,
success: (res) => {
console.log(res);
}
})

image.png

启动 wx-java-miniapp项目:

image.png

启动shiro-jwt-demo项目:

image.png

Postman测试认证:

image.png

携带token访问:

image.png

最后

以上就是基于Shiro、JWT实现微信小程序登录完整例子的逻辑过程说明及其实现。

Java 微信小程序
使用 Travis 打造 SpringBoot 应用持续集成和自动部署 | Travis CI 初体验
荣耀加冕,追梦不休 | 我的大学时光
© 2020 码之泪殇
Powered by hexo | Theme is blank