Shiro +JWT 自定義spring-boot-starter

Shiro 是什麼 ?

不多說了,一個java 安全認證框架 ,類似於Spring-security,兩者異同網上多了去,個人比較喜歡shiro ,簡單易懂。

JWT 是什麼 ?

java web token 的簡稱

爲什麼要 shiro + JWT ?

現在微服務架構,前後端分離架構,綜合 PC、 APP 、小程序、微信等,採用一個令牌作爲登錄用戶身份的象徵,不再用原來的瀏覽器Session作爲登錄憑證。
小程序 微信 等應該沒有 cookie 、session概念。
採用統一 jwt 一勞永逸解決所有前端過來的請求身份認證問題,減輕服務器內存存儲壓力。

JWT 的利弊網上分析的夠透徹了,這裏不多說了。

SpringBoot 自定義starter是什麼 ?

springboot 精髓之一,不多介紹。如果還不明白springboot的精髓,請務必查看下面大神的微信文章:
https://mp.weixin.qq.com/s/SY7H7EjLN5CpE33k1QKLfA

爲什麼要自定義一個starter ?

減輕新項目的配置複雜度,統一配置管理。不會自定義starter的,強烈建議查看下面大神的博客:
https://www.jianshu.com/p/4735fe7ae921

開始搞起來

建一個maven工程

工程名稱叫做 shirojwt-spring-boot-starter,先貼出 pom.xml文件內容:

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!--================  Spring Boot ===================== -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>org.guzt</groupId>
    <artifactId>shirojwt-spring-boot-starter</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <java.version>1.8</java.version>

        <!-- Shiro JWT Begin -->
        <java.jwt.version>3.10.2</java.jwt.version>
        <shiro.spring.version>1.5.2</shiro.spring.version>
        <!-- Shiro JWT End -->
    </properties>


    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
        </dependency>

        <!-- 用於使用Spring AOP和AspectJ實現面向切面編程 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
        </dependency>

        <!-- shiro -->
        <!-- https://mvnrepository.com/artifact/org.apache.shiro/shiro-spring -->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>${shiro.spring.version}</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>${java.jwt.version}</version>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>${java.version}</source>
                    <target>${java.version}</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <!-- 生成sources源碼包的插件 -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-source-plugin</artifactId>
                <configuration>
                    <attach>true</attach>
                </configuration>
                <executions>
                    <execution>
                        <id>attach-sources</id>
                        <!--意思是在什麼階段打包源文件-->
                        <phase>package</phase>
                        <goals>
                            <goal>jar-no-fork</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>install-sources</id>
                        <!--意思是在什麼階段打包源文件-->
                        <phase>install</phase>
                        <goals>
                            <goal>jar-no-fork</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

上面都是要用到的jar,其實starter最重要的兩個jar依賴:
spring-boot-starter 和 spring-boot-configuration-processor (用於配置文件屬性封裝)

spring-boot-starter-aop 其實可以不用引入,但如果你在項目中要使用shiro的權限控制註解時,請務必保證你的項目裏面有 spring-boot-starter-aop 這個依賴。

spring-web 依賴主要是這個自定義的starter裏面用到了一些Request相關內容,不是主要依賴。

shiro-spring 和 java-jwt 主角不用說了

maven工程總體包目錄結構

在這裏插入圖片描述
紅框的文件包是主要的

總體設計思路

這裏說一下總體設計思路,代碼全部貼出太多了,文章最後放上 github地址,裏面有使用說明 README.md

  • 相關的屬性部分有默認值,可以在application.yml裏面修改
  • 認證,授權兩個方法必須讓引入starter的開發者根據自己業務重寫
  • 應該有一個當認證失敗時,供開發者調用的方法,開發者在這個方法裏面實現具體認證失敗時跳轉還是輸出錯誤信息
  • 必須注入自定義的過濾器
  • 必須可以自定義路徑過濾規則
  • 有基於註解認證的 和 基於角色權限URL認證方式的

這裏不用多說了,可以查看github上的源碼,另外附上使用說明和測試用例的github地址

shirojwt-spring-boot-starter 源碼地址


https://github.com/dwhgygzt/shirojwt-spring-boot-starter


測試工程源碼地址


https://github.com/dwhgygzt/myshirojwt-test


使用方式

代碼下載本地,mvn clean install 之後

配置:

pom.xml 文件引入如下配置

<dependency>
    <groupId>org.guzt</groupId>
    <artifactId>shirojwt-spring-boot-starter</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

引入配置後,其實 application.yml不用配置任何信息即可啓用 shiro jwt,
當然你可以根據下面的常用默認值決定是否配置

  1. 配置文件默認登錄路徑 /api/login
  2. 配置文件默認退出路徑 /api/logout
  3. 默認Header裏jwt的名稱 Authorization
  4. 其他默認值例如token超時時限,刷新時限請查看源碼,默認1個小時

如果需要配置不同信息,yml文件配置也十分簡單:


shirojwt:
  login-url: /api/login
  logout-url: /api/logout
  jwtIssuer: yourIssuerName
  token-header-key: Authorization

用法:

1. 用戶登錄後生成 token方法

下面是一個簡單的測試類

@RestController
@RequestMapping("/api")
public class UserInfoController {

    // 用於查詢用戶信息的 service
    @Resource
    private UserInfoService userInfoService;

    @PostMapping("login")
    public Map<String, String> login(String userName, String password) {
        // 你的登錄代碼驗證邏輯
        Map<String, String> loginInfo = userInfoService.login(userName, password);
        if (loginInfo == null || loginInfo.isEmpty()) {
            BusinessException.create("用戶名或密碼錯誤");
        }
        // 登錄驗證通過後 生成token給前端
        assert loginInfo != null;
        loginInfo.put("token", JwtUtil.sign(userName, 
                        loginInfo.get(UserInfoService.passwordKey), 
                        loginInfo.get(UserInfoService.saltKey)));
        return loginInfo;
    }

    @GetMapping("logout")
    public String logout() {
        Subject subject = SecurityUtils.getSubject();
        if (subject != null) {
            subject.logout();
        }
        return "退出成功";
    }
}

2. 前端訪問後臺接口,http請求中 HEADER 必須帶有token

KEY VALUE
Authorization 登錄接口獲得的token值

3. shiro驗證token合法性

重寫 JwtBussinessService 類即可,覆蓋裏面幾個方法,

java 代碼中使用如下:

@Service
public class MyJwtBussinessService extends JwtBussinessService {

    private Logger logger = LoggerFactory.getLogger(this.getClass());


    public MyJwtBussinessService() {
        logger.info("MyJwtBussinessService 初始化");
    }

    @Override
    public AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals, String realmName) {
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        logger.debug("進入 授權 doGetAuthorizationInfo");
        logger.debug("the toke is  {}", principals.toString());
        String userName = JwtUtil.getUserName(principals.toString());
        // 模擬從數據庫中根據用戶名查詢出用戶
        Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
        String spit = ",";
        // 該用戶具有哪些權限
        for (String permission : user.get(UserInfoService.permissionsKey).split(spit)) {
            authorizationInfo.addStringPermission(permission);
        }
        // 該用戶具有哪些角色
        for (String role : user.get(UserInfoService.rolesKey).split(spit)) {
            authorizationInfo.addRole(role);
        }

        return authorizationInfo;
    }

    @Override
    public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth, String realmName) throws AuthenticationException {
        String token = (String) auth.getCredentials();
        logger.debug("進入 認證 doGetAuthenticationInfo");
        logger.debug("the toke is  {}", token);
        // token是否過期
        Date expiresDate = JwtUtil.getExpiresAt(token);
        if (expiresDate == null) {
            throw new IncorrectCredentialsException("token 不正確");
        } else if (expiresDate.before(new Date())) {
            throw new ExpiredCredentialsException("token 過期了");
        }
        // 驗證 token是否有效
        String userName = JwtUtil.getUserName(token);
        if (userName == null) {
            throw new IncorrectCredentialsException("token 不正確");
        }
        // 驗證用戶是否存在
        Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
        if (user == null) {
            throw new UnknownAccountException("用戶不存在");
        }
        // 用戶最終認證
        String password = user.get(UserInfoService.passwordKey);
        String salt = user.get(UserInfoService.saltKey);
        return new SimpleAuthenticationInfo(token, password, ByteSource.Util.bytes(salt), realmName);
    }

    @Override
    public void onAccessDenied(HttpServletRequest request, HttpServletResponse response, boolean isTokenExists, ShiroException ex) throws IOException {
        //  這裏的 ShiroException 分爲兩類 一類認證異常  一類權限檢查不通過異常
        //  AuthenticationException 認證異常
        //  AuthorizationException 權限檢查不通過異常
        defaultPrintJson(response, "{\"code\":\"-1\",\"data\":{\"bussinessCode\":\"401\"},\"message\":\"" + ex.getLocalizedMessage() + "\"}");
    }

    @Override
    public String refreshOldToken(String oldToken) {
        // 刷新 token
        String userName = JwtUtil.getUserName(oldToken);
        Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
        return JwtUtil.sign(userName, user.get(UserInfoService.passwordKey), user.get(UserInfoService.saltKey));
    }
}

默認已經對swagger進行的過濾,可直接訪問swagger頁面
如果要引入其他Bean 請務必使用懶加載方式,防止自定義的AOP失效,因爲ExtraFilterRule所在的配置類會被提前初始化

@Component
public class MyExtraFilterRule extends ExtraFilterRule {
    
    // 請務必使用懶加載方式注入bean
    // yourBusinessBean 例如爲菜單查詢類,查詢出所有按鈕權限菜單
    @Lazy
    @Service
    private YourBusinessBean yourBusinessBean;

    @Override
    public void setExtraFilterRule(LinkedHashMap<String, String> filterRuleMap) {
        // 不檢查某些路徑
        filterRuleMap.put("/api/init", "noSessionCreation,anon");
       // 添加自定義過濾器配置 myTestFilter 就是自己的過濾器
        filterRuleMap.put("/api/selectUserInfoByUserName", "noSessionCreation,myTestFilter,jwt,jwtPerms[dd]");
    }
}

5. 添加自定義過濾器

如果要引入其他Bean 請務必使用懶加載方式,防止自定義的AOP失效,因爲ExtraFilter所在的配置類會被提前初始化

@Component
public class MyExtraFilter extends ExtraFilter {
    @Override
    public void setExtraFilter(LinkedHashMap<String, Filter> filterMap) {
        filterMap.put("myTestFilter", new MyTestFilter());
    }
}

/**
 * 自定義過濾器, 請勿使用 @Bean 或 @Service
 *
 * admin
 */
public class MyTestFilter extends AuthorizationFilter {

    protected Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        logger.info("沒有別的事情,就是表示進過了過濾器 MyTestFilter");
        return Boolean.TRUE;
    }
}

6. 默認已經添加的過濾器配置

名稱 作用
jwt jwt認證
myCorsFilter 支持跨域,默認支持
jwtPerms URL 上的權限認證
jwtRoles URL 上的角色認證

7. 基於URL的權限認證

一般情況下針對基於URL的權限認證,說白了就是按鈕權限認證,也即對後臺某個Controller方法的權限認證。
所謂權限認證,就是你是否有相應的權限或角色標識才可調用該controller裏面的某個方法。

這裏做法一般兩種, 1. 基於權限註解 2. 基於URL過濾器配置

基於註解

用法如下:

/**
 * 測試 shirojwt
 *
 * @author admin
 */
@RestController
@RequestMapping("/api")
public class UserInfoController {
 
    // 需要 權限 admin:update 纔可訪問這個方法
    @RequiresPermissions("admin:update")
    @PutMapping("updateUser")
    public String updateUser(@RequestBody Map<String, String> user) {
        userInfoService.updateUser(user);
        return "success";
    }
 
    // 需要 admin或user角色才能訪問這個方法
    @RequiresRoles(value = {"admin","user"})
    @GetMapping("getUserInfoByUserName")
    public Map<String, String> getUserInfoByUserName(String userName) {
        return userInfoService.getUserByUserName(userName);
    }

}

當用戶訪問 上面controller層裏面任意一個方法時,shiro會調用上文中 doGetAuthorizationInfo
方法,該方法作用就是從數據庫或緩存中根據 JWT 取出用戶具有的角色和權限,然後Shiro框架會自動判定用戶是否具有
訪問該方法的權限,如果沒有將拋出 UnauthorizedException 異常, 用戶可使用全局異常進行捕獲反饋給前端。

這裏說明一下 在此之前用戶已經進過JWT 認證了,如果認證不通過不會到這一步的。

基於URL過濾器配置

上文已經提過,本starter已經默認註冊了 權限角色驗證的過濾器,且支持自定義URL過濾配置

名稱 作用
jwtPerms URL 上的權限認證
jwtRoles URL 上的角色認證

重複上面的文章 覆寫ExtraFilterRule類即可。

默認已經對swagger進行的過濾,可直接訪問swagger頁面
如果要引入其他Bean 請務必使用懶加載方式,防止自定義的AOP失效,因爲ExtraFilterRule所在的配置類會被提前初始化

@Component
public class MyExtraFilterRule extends ExtraFilterRule {
    
    // 請務必使用懶加載方式注入bean
    // MenuRoleService 角色菜單權限關係處理service
    @Lazy
    @Service
    private MenuRoleService menuRoleService;

    @Override
    public void setExtraFilterRule(LinkedHashMap<String, String> filterRuleMap) {
        List<Menu> buttons = menuRoleService.listAllButtonMenu();
        for( Menu item : buttons ){
            // item.getPathUrl() 是按鈕對應的後端路徑
            // item.getPerm() 是按鈕應的權限標識,表示這個URL需要該權限標識才可訪問
            filterRuleMap.put(item.getPathUrl(), "noSessionCreation,jwt,jwtPerms["+ item.getPerm() +"]");
        }
    }
}

這裏如果用戶權限認證不通過時候,會調用上文中 MyJwtBussinessService 裏面的 onAccessDenied 方法。
此時 ShiroException 爲 UnauthorizedException,你可以根據具體的異常類別做出打印或跳轉信息給前端。

這裏列出 ShiroException 的具體常用的幾種子類,以便你做出具體的業務邏輯處理。

類別 說明
NoTokenAuthenticationException 【jwt驗證】 header裏面未攜帶jwt
ProgramErrorAuthenticationException 【jwt驗證】jwt驗證程序500錯誤
ExpiredCredentialsException 【jwt驗證】jwt過期,這個需要你自己認證方法裏面拋出
ExpiredCredentialsException 【jwt驗證】jwt過期,這個需要你自己認證方法裏面拋出
IncorrectCredentialsException 【jwt驗證】jwt格式錯誤,這個需要你自己認證方法裏面拋出
UnauthorizedException 【權限驗證】 權限認證不通過統一拋出該異常

8. 基於URL的動態權限認證

所謂動態 就是可以在管理系統裏面隨意添加一條或刪除一條URL 認證記錄,這裏暫不建議這樣做,
這裏非要做其實是要刷新Shiro裏面緩存的URL 攔截配置,說穿了就是將裏面的一個LinkHashMap清空重新
填充數據。

  • 不建議原因1 現在都是分佈式部署,你要刷新全部的機器上的應用
  • 不建議原因2 一般都是有新功能上線纔會有這樣的事情,建議滾動發佈即可,挨個重啓服務測試
  • 不建議原因3 現在很多的微服務認證都轉向API網關層認證,當然網關認證也可結合shirojwt,網關一般也是多臺部署
    一般滾動發佈即可。

9. 關於緩存管理

這裏建議開發自行 在認證 和 授權兩個方法裏面通過redis緩存進行自定義邏輯處理。
例如簡單的獲取用戶是否存在驗證邏輯:

@Service
public class CurrentUserServiceImpl implements CurrentUserService {

    public CurrentUserVO getCurrentUserFromCacheAndDb(String authToken) {
        if (StrUtil.isEmpty(authToken)) {
            logger.debug("authToken is null");
            BusinessException.create(CommonBusinessCode.AUTHTOKEN_NOTFOUND);
        }
        CurrentUserVO vo = null;
       // 先從緩存裏面取 token
        RBucket<String> tokenBucket = redissonClient.getBucket(
                applicationName + StrUtil.format(SysConstants.REDIS_LOGIN_TOKEN_KEY, SecureUtil.md5(authToken)));
        if (!tokenBucket.isExists()) {
            logger.debug("authToken {} not in redis", authToken);
            BusinessException.create(CommonBusinessCode.AUTHTOKEN_INVALID);
        }
       // 然後根據token 取用戶
        RBucket<CurrentUserVO> userBucket = redissonClient.getBucket(
                applicationName + StrUtil.format(SysConstants.REDIS_LOGIN_USER_KEY, JwtUtil.getUserName(authToken)));
        if (userBucket.isExists()) {
            vo = userBucket.get();
            CurrentUserContext.remove();
            CurrentUserContext.setCurrentUser(vo);
        }
        // 緩存不存在,從數據庫中加載用戶信息
        if (ObjectUtil.isEmpty(vo)) {
            logger.debug("currentUser({}) 獲取不到信息 從數據庫中查詢該用戶", JwtUtil.getUserName(authToken));
            CurrentUserContext.remove();
            sysUserAggregateService.getCurrentUserFromDb(JwtUtil.getUserName(authToken), ExtendNetUtil.getSpringContextRequestIp(), SecureUtil.md5(authToken));
            vo = CurrentUserContext.getCurrentUser();
            // 放入緩存中
            userBucket.set(vo, shiroJwtProperties.getTokenExpireSeconds(), TimeUnit.SECONDS);
        }

        if (ObjectUtil.isEmpty(vo)) {
            logger.debug("currentUser({}) 緩存和數據庫中都獲取不到用戶信息", JwtUtil.getUserName(authToken));
            BusinessException.create(CommonBusinessCode.CURRENT_USER_NOTFOUND);
        }

        return vo;
    }

}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章