基於Shiro | JWT實現微信小程序登陸

前言

最近在作畢業設計,涉及到微信小程序的開發,要求前端小程序用戶使用微信身份登陸,登錄成功後,後臺返回自定義登陸狀態token給小程序,後續小程序發送API請求都須要攜帶token才能訪問後臺數據。html

本文是對接微信小程序,實現自定義登陸狀態的一個完整示例,實現了小程序的自定義登錄,將自定義登錄態token返回給小程序做爲登錄憑證。用戶的信息保存在數據庫中,登錄態token緩存在redis中。涉及的技術棧:前端

  • SpringBoot -> 後端基礎環境
  • Shiro -> 安全框架
  • JWT -> 加密token
  • MySQL -> 主庫,存儲業務數據
  • MyBatis-Plus -> 操做數據庫
  • Redis -> 緩存token和其餘熱點數據
  • Lombok -> 簡化開發
  • FastJson -> json消息處理
  • RestTemplate -> 優雅的處理web請求

項目GitHub地址:https://github.com/gongsir0630/shiro-jwt-demojava

特性

  • 基於WxJava對接微信小程序,實現用戶登陸、消息處理
  • 支持Shiro註解編程,保持高度的靈活性
  • 使用JWT進行校驗,徹底實現無狀態鑑權
  • 使用Redis存儲自定義登錄態token,支持過時時間
  • 支持跨域請求

準備工做

基礎知識預備:mysql

  • 具有SpringBoot基礎知識而且會使用基本註解;
  • 瞭解JWT(Json Web Token)的基本概念,而且會簡單操做JWT的 JAVA SDK;
  • 瞭解Shiro的基本概念:Subject、Realm、SecurityManager等(建議去官網學習一下)

其餘說明:git

本文只對shiro和jwt整合進行介紹說明,具體的微信登陸實現是使用RestTemplate調用我本身的wx-java-miniapp項目,該項目基於WxJava實現,支持多個小程序登陸、消息處理。github

本文使用如下調用處理便可:web

// 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

項目地址:redis

  • wx-java-miniapp -> 能夠直接部署使用
  • WxJava -> 官方SDK

總體思路

先了解一下小程序官方登陸流程,官方說明戳這裏算法

小程序登陸流程spring

  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:

<?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。

# 設置日誌級別
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

/**
 * @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.classargs);
    }

    /**
     * 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

/**
 * @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

/**
 * @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

/**
 * @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

/**
 * @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

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

/**
 * @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

/**
 * @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

/**
 * @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

/**
 * @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

/**
 * @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

 * @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

 * @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

/**
 * @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

/**
 * @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

/**
 * @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

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

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註解支持
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

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<UserMapperUserimplements 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

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

wx.login({
   timeout3000,
   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實現微信小程序登陸完整例子的邏輯過程說明及其實現。

相關文章
相關標籤/搜索