Springboot整合shiro、jwt、redis總結


Springboot整合shiro、jwt、redis總結

涉及技術:

1. SpringBoot + Mybatis核心框架
2. PageHelper插件 + 通用Mapper插件
3. Shiro + Java-JWT無狀態鑒權認證機制
4. Redis(Jedis)緩存框架

5. PostgreSql

實現

完全使用了 Shiro 的注解配置,保持高度的靈活性。

放棄 Cookie ,Session ,使用JWT進行鑒權,完全實現無狀態鑒權。

JWT 密鑰支持過期時間。

對跨域提供支持。

 

數據源

 

 

 

 

由於開始是按照mysql方言寫的所以創建表時遇到些坑,

1.在postgre里user、password是關鍵字需要加冒號,

2.Int自增應該寫成serial類型: 

先創建序列,然后設置字段的自增

CREATE SEQUENCE users_id_seq 

START WITH 1 

INCREMENT BY 1 

NO MINVALUE 

NO MAXVALUE 

CACHE 1;

alter table users alter column id set default nextval('users_id_seq');  

3.關於外鍵要直接寫在外鍵后面  role_id int not NULL references role (id)

 

 

4.配置文件如下(用的Druid連接池)

 

 

 

 

 

 

5.在做小demo期間還學了Mybatis Generator逆向生成代碼:

很好用的偷懶神器,先配置src\main\resources\generator\generatorConfig.xml文件,在項目根目錄下(前提是配置了mvn)在IDEA的Maven窗口Plugins中雙擊執行),可自動生成Model、Mapper、MapperXML。

Shiro + Java-JWT實現無狀態鑒權機制(Token)

首先Post用戶名與密碼到user/login進行登入,如果成功返回一個加密的AccessToken,失敗的話直接返回401錯誤(帳號或密碼不正確),以后訪問都帶上這個AccessToken即可,鑒權流程主要是重寫了Shiro的入口過濾器JWTFilter(BasicHttpAuthenticationFilter),判斷請求

Header里面是否包含Authorization字段,有就進行Shiro的Token登錄認證授權(用戶訪問每一個需要權限的請求必須在Header中添加Authorization字段存放AccessToken),沒有就以游客直接訪問(有權限管控的話,以游客訪問就會被攔截)

 

主要學習的幾個概念:

無狀態

微服務集群中的每個服務,對外提供的都使用RESTful風格的接口。而RESTful風格的一個最重要的規范就是:服務的無狀態性,即

服務端不保存任何客戶端請求者信息

客戶端的每次請求必須具備自描述信息,通過這些信息識別客戶端身份

客戶端請求不依賴服務端的信息,多次請求不需要必須訪問到同一台服務器

服務端的集群和狀態對客戶端透明

服務端可以任意的遷移和伸縮(可以方便的進行集群化部署)

減小服務端存儲壓力

Jwt

JSON Web Token(JWT)是目前最流行的跨域身份驗證解決方案

 

 

 

 

 

 

客戶端接收服務器返回的JWT,將其存儲在Cookie中。

此后,客戶端將在與服務器交互中都會帶JWT。如果將它存儲在Cookie中,就可以自動發送,但是不會跨域,因此一般是將它放入HTTP請求的Header Authorization字段中。

Authorization: Bearer

當跨域時,也可以將JWT被放置於POST請求的數據主體中。

JWT頭部分是一個描述JWT元數據的JSON對象    

有效載荷部分,是JWT的主體內容部分,也是一個JSON對象,包含需要傳遞的數據。

簽名哈希部分是對上面兩部分數據簽名,通過指定的算法生成哈希,以確保數據不會被篡改。

在網上搜了些關於加密的算法,一般采用MD5+鹽的算法,但是當兩個用戶的明文密碼相同時進行加密,會發現數據庫中存在相同結構的暗文密碼,所以采用AES-128 + Base64是以帳號+密碼的形式進行加密密碼,因為帳號具有唯一性,所以也不會出現相同結構的暗文密碼這個問題

 

 

Shiro

 

 

 

 

配置

寫了獲取當前登錄用戶工具類、Json和Object的互相轉換的類、jwt工具類

我把工具類粘貼到了博客園https://www.cnblogs.com/Treesir/p/11600245.html

AES加密解密工具類、Base64工具是引用博客https://www.jianshu.com/p/f37f8c295057

關於redis的配置是粘貼https://www.cnblogs.com/GodHeng/p/9301330.html的,引用了博主的JedisUtil類

構建URL

ResponseBean.java

既然想要實現 restful,那我們要保證每次返回的格式都是相同的,因此建立了一個 ResponseBean 來統一返回的格式。

 

 

 

 

仿造博客寫了一個 CustomUnauthorizedException.java

 

 

 

Controller

主要實現了登陸、新增用戶、通過制定id獲取指定用戶,其中用到了通用mapper進行查詢,剛開始想用前幾天看到jpa搜了下看到有類似的通用mapper。

/**
 * JWT過濾
         * @return
        * @author guxiangdong
        * @creed: Talk is cheap,show me the code
        * @date 2019/9/25 13:59
        */
@RestController
@RequestMapping("/users")
@PropertySource("classpath:config.properties")
public class UserController {

    /**
     * RefreshToken過期時間
     */
    @Value("${refreshTokenExpireTime}")
    private String refreshTokenExpireTime;

    private final UserUtil userUtil;

    private final IUserService userService;

    @Autowired
    public UserController(UserUtil userUtil, IUserService userService) {
        this.userUtil = userUtil;
        this.userService = userService;
    }
    /**
     * 獲取用戶列表
     */
    @GetMapping
    @RequiresPermissions(logical = Logical.AND, value = {"user:view"})
    public ResponseBean user(@Validated BaseDto baseDto) {
        if (baseDto.getPage() == null || baseDto.getRows() == null) {
            baseDto.setPage(1);
            baseDto.setRows(10);
        }
        PageHelper.startPage(baseDto.getPage(), baseDto.getRows());
        List<UsersDto> usersDtos = userService.selectAll();
        PageInfo<UsersDto> selectPage = new PageInfo<UsersDto>(usersDtos);
        if (usersDtos == null || usersDtos.size() <= 0) {
            throw new CustomException("查詢失敗(Query Failure)");
        }
        Map<String, Object> result = new HashMap<String, Object>(16);
        result.put("count", selectPage.getTotal());
        result.put("data", selectPage.getList());
        return new ResponseBean(HttpStatus.OK.value(), "查詢成功(Query was successful)", result);
    }

    /**
     * 登錄授權
     */
    @PostMapping("/login")
    public ResponseBean login(@Validated(UserLoginValidGroup.class) @RequestBody UsersDto usersDto, HttpServletResponse httpServletResponse) {
        // 查詢數據庫中的帳號信息
        UsersDto usersDtoTemp = new UsersDto();
        usersDtoTemp.setAccount(usersDto.getAccount());
        usersDtoTemp = userService.selectOne(usersDtoTemp);
        if (usersDtoTemp == null) {
            throw new CustomUnauthorizedException("該帳號不存在(The account does not exist.)");
        }
        // 密碼進行AES解密
        String key = AesCipherUtil.deCrypto(usersDtoTemp.getPsword());
        // 因為密碼加密是以帳號+密碼的形式進行加密的,所以解密后的對比是帳號+密碼
        if (key.equals(usersDto.getAccount() + usersDto.getPsword())) {
            // 清除可能存在的Shiro權限信息緩存
            if (JedisUtil.exists(Constant.PREFIX_SHIRO_CACHE + usersDto.getAccount())) {
                JedisUtil.delKey(Constant.PREFIX_SHIRO_CACHE + usersDto.getAccount());
            }
            // 設置RefreshToken,時間戳為當前時間戳,直接設置即可(不用先刪后設,會覆蓋已有的RefreshToken)
            String currentTimeMillis = String.valueOf(System.currentTimeMillis());
            JedisUtil.setObject(Constant.PREFIX_SHIRO_REFRESH_TOKEN + usersDto.getAccount(), currentTimeMillis, Integer.parseInt(refreshTokenExpireTime));
//            // 從Header中Authorization返回AccessToken,時間戳為當前時間戳
            String token = JwtUtil.sign(usersDto.getAccount(), currentTimeMillis);
            httpServletResponse.setHeader("Authorization", token);
            httpServletResponse.setHeader("Access-Control-Expose-Headers", "Authorization");
            return new ResponseBean(HttpStatus.OK.value(), "登錄成功(Login Success.)", null);
        } else {
            throw new CustomUnauthorizedException("帳號或密碼錯誤(Account or Password Error.)");
        }
    }

    /**
     * 測試登錄
     */
    @GetMapping("/article")
    public ResponseBean article() {
        Subject subject = SecurityUtils.getSubject();
        // 登錄了返回true
        if (subject.isAuthenticated()) {
            return new ResponseBean(HttpStatus.OK.value(), "您已經登錄了(You are already logged in)", null);
        } else {
            return new ResponseBean(HttpStatus.OK.value(), "你是游客(You are guest)", null);
        }
    }

    /**
     * 獲取指定用戶
     */
    @GetMapping("/{id}")
    @RequiresPermissions(logical = Logical.AND, value = {"user:view"})
    public ResponseBean findById(@PathVariable("id") Integer id) {
        UsersDto usersDto = userService.selectByPrimaryKey(id);
        if (usersDto == null) {
            throw new CustomException("查詢失敗(Query Failure)");
        }
        return new ResponseBean(HttpStatus.OK.value(), "查詢成功(Query was successful)", usersDto);
    }

    /**
     * 新增用戶
     */
    @PostMapping("/add")
    @RequiresPermissions(logical = Logical.AND, value = {"user:edit"})
    public ResponseBean add(@Validated(UserEditValidGroup.class) @RequestBody UsersDto UsersDto ,HttpServletResponse httpServletResponse) {
        // 判斷當前帳號是否存在
        UsersDto userDtoTemp = new UsersDto();
        userDtoTemp.setAccount(UsersDto.getAccount());
        userDtoTemp = userService.selectOne(userDtoTemp);
        if (userDtoTemp != null && StringUtil.isNotBlank(userDtoTemp.getPsword())) {
            throw new CustomUnauthorizedException("該帳號已存在(Account exist.)");
        }
        UsersDto.setRegTime(new Date());
        // 密碼以帳號+密碼的形式進行AES加密
        if (UsersDto.getPsword().length() > Constant.PASSWORD_MAX_LEN) {
            throw new CustomException("密碼最多8位(Psword up to 8 bits.)");
        }
        String key = AesCipherUtil.enCrypto(UsersDto.getAccount() + UsersDto.getPsword());
        UsersDto.setPsword(key);
        int count = userService.insert(UsersDto);
        if (count <= 0) {
            throw new CustomException("新增失敗(Insert Failure)");
        }
        return new ResponseBean(HttpStatus.OK.value(), "新增成功(Insert Success)", UsersDto);
    }
}

 

配置 Shiro

實現JWTToken

JWTToken 差不多就是 Shiro 用戶名密碼的載體。因為前后端分離,服務器無需保存用戶狀態,所以不需要 RememberMe 這類功能,實現下 AuthenticationToken 接口即可

 

 

 

實現Realm

realm 的用於處理用戶是否合法的這一塊,需要我們自己實現。

這里要重寫supports方法不然會報錯

 

 

AuthenticationInfo代表了用戶的角色信息集合,AuthorizationInfo代表了角色的權限信息集合,PrincipalCollection是一個身份集合,

 

 

 

 

 

 

 

重寫 Filter

所有的請求都會先經過 Filter,所以我們繼承官方的 BasicHttpAuthenticationFilter ,並且重寫鑒權的方法代碼的執行流程 preHandle(對跨域提供支持) -> isAccessAllowed(登入用戶和游客看到的內容是不同的,如果在這里返回了false,請求會被直接攔截,用戶看不到任何東西。所以在這里返回true,Controller中可以通過 subject.isAuthenticated() 來判斷用戶是否登入如果有些資源只有登入用戶才能訪問,只需要在方法上面加上 @RequiresAuthentication 注解即可但是這樣做有一個缺點,就是不能夠對GET,POST等請求進行分別過濾鑒權(因為我們重寫了官方的方法),但實際上對應用影響不大) -> isLoginAttempt (檢測Header里面是否包含Authorization字段,有就進行Token登錄認證授權)-> executeLogin (進行登陸認證授權)。

配置Shiro

@Configuration

public class ShiroConfig {

 

    /**

     * 配置使用自定義Realm,關閉Shiro自帶的session

     * 詳情見文檔

http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29

     */

    @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")

    @Bean("securityManager")

    public DefaultWebSecurityManager defaultWebSecurityManager(UsersRealm usersRealm) {

        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();

        // 使用自定義Realm

        defaultWebSecurityManager.setRealm(usersRealm);

        // 關閉Shiro自帶的session

        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();

        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();

        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);

        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);

        defaultWebSecurityManager.setSubjectDAO(subjectDAO);

        // 設置自定義Cache緩存

        defaultWebSecurityManager.setCacheManager(new CustomCacheManager());

        return defaultWebSecurityManager;

    }

[顧祥東1] 

     * Shiro自帶攔截器配置規則

     * rest:比如/admins/user/**=rest[user],根據請求的方法,相當於/admins/user/**=perms[user:method] ,其中method為post,get,delete等

     * port:比如/admins/user/**=port[8081],當請求的url的端口不是8081是跳轉到schemal://serverName:8081?queryString,其中schmal是協議http或https等,serverName是你訪問的host,8081是url配置里port的端口,queryString是你訪問的url里的?后面的參數

     * perms:比如/admins/user/**=perms[user:add:*],perms參數可以寫多個,多個時必須加上引號,並且參數之間用逗號分割,比如/admins/user/**=perms["user:add:*,user:modify:*"],當有多個參數時必須每個參數都通過才通過,想當於isPermitedAll()方法

     * roles:比如/admins/user/**=roles[admin],參數可以寫多個,多個時必須加上引號,並且參數之間用逗號分割,當有多個參數時,比如/admins/user/**=roles["admin,guest"],每個參數通過才算通過,相當於hasAllRoles()方法。//要實現or的效果看http://zgzty.blog.163.com/blog/static/83831226201302983358670/

     * anon:比如/admins/**=anon 沒有參數,表示可以匿名使用

     * authc:比如/admins/user/**=authc表示需要認證才能使用,沒有參數

     * authcBasic:比如/admins/user/**=authcBasic沒有參數表示httpBasic認證

     * ssl:比如/admins/user/**=ssl沒有參數,表示安全的url請求,協議為https

     * user:比如/admins/user/**=user沒有參數表示必須存在用戶,當登入操作時不做檢查

     * 詳情見文檔 http://shiro.apache.org/web.html#urls-

     */

    @Bean("shiroFilter")

    public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {

        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();

        Map<String, Filter> filterMap = new HashMap<>(16);

        filterMap.put("jwt", new JwtFilter());

        factoryBean.setFilters(filterMap);

        factoryBean.setSecurityManager(securityManager);

        LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>(16);

        filterChainDefinitionMap.put("/**", "jwt");

        factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);

        return factoryBean;

    }

 

    /**

     * 添加注解支持

     */

    @Bean

    @DependsOn("lifecycleBeanPostProcessor")

    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {

        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();

        // 強制使用cglib,防止重復代理和可能引起代理出錯的問題,https://zhuanlan.zhihu.com/p/29161098

        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);

        return defaultAdvisorAutoProxyCreator;

    }

 

    @Bean

    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {

        return new LifecycleBeanPostProcessor();

    }

 

    @Bean

    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {

        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();

        advisor.setSecurityManager(securityManager);

        return advisor;

    }

}

 

SpringBoot + Shiro + JWT集成Redis緩存(Jedis)

Redis部分還沒有太理解,大部分是仿照

https://blog.csdn.net/qq_31897023/article/details/89082541

大概過程

寫配置文件config.properties(Redis的配置屬性)

JedisConfig.java(JedisPool啟動配置Bean,本來是直接將JedisUtil注入為Bean,每次使用直接@Autowired注入使用即可,但是在重寫Shiro的CustomCache無法注入JedisUtil,所以就改成靜態注入JedisPool連接池,JedisUtil工具類還是直接調用靜態方法,無需@Autowired注入 取自https://blog.csdn.net/W_Z_W_888/article/details/79979103

 

引用JedisUtil(Jedis工具類)、 StringUtil、 SerializableUtil

重寫Shiro的Cache保存讀取和Shiro的Cache管理器

 

 

重寫Shiro的Cache保存讀取和Shiro的Cache管理器

CustomCache.java(Cache保存讀取)

 
/**
 * 重寫Shiro的Cache保存讀取
*/
public class CustomCache<K,V> implements Cache<K,V> {
 
    /**
     * redis-key-前綴-shiro:cache:
     */
    public final static String PREFIX_SHIRO_CACHE = "shiro:cache:";
 
    /**
     * 過期時間-5分鍾
     */
    private static final Integer EXPIRE_TIME = 5 * 60 * 1000;
 
    /**
     * 緩存的key名稱獲取為shiro:cache:account
     * @param key
     * @return java.lang.String
     * @author Wang926454
     * @date 2018/9/4 18:33
     */
    private String getKey(Object key){
        return PREFIX_SHIRO_CACHE + JWTUtil.getUsername(key.toString());
    }
 
    /**
     * 獲取緩存
     */
    @Override
    public Object get(Object key) throws CacheException {
        if(!JedisUtil.exists(this.getKey(key))){
            return null;
        }
        return JedisUtil.getObject(this.getKey(key));
    }
 
    /**
     * 保存緩存
     */
    @Override
    public Object put(Object key, Object value) throws CacheException {
        // 設置Redis的Shiro緩存
        return JedisUtil.setObject(this.getKey(key), value, EXPIRE_TIME);
    }
 
    /**
     * 移除緩存
     */
    @Override
    public Object remove(Object key) throws CacheException {
        if(!JedisUtil.exists(this.getKey(key))){
            return null;
        }
        JedisUtil.delKey(this.getKey(key));
        return null;
    }
 
    /**
     * 清空所有緩存
     */
    @Override
    public void clear() throws CacheException {
        JedisUtil.getJedis().flushDB();
    }
 
    /**
     * 緩存的個數
     */
    @Override
    public int size() {
        Long size = JedisUtil.getJedis().dbSize();
        return size.intValue();
    }
 
    /**
     * 獲取所有的key
     */
    @Override
    public Set keys() {
        Set<byte[]> keys = JedisUtil.getJedis().keys(new String("*").getBytes());
        Set<Object> set = new HashSet<Object>();
        for (byte[] bs : keys) {
            set.add(SerializableUtil.unserializable(bs));
        }
        return set;
    }
 
    /**
     * 獲取所有的value
     */
    @Override
    public Collection values() {
        Set keys = this.keys();
        List<Object> values = new ArrayList<Object>();
        for (Object key : keys) {
            values.add(JedisUtil.getObject(this.getKey(key)));
        }
        return values;
    }
}

CustomCacheManager.java(緩存(Cache)管理器)

/**
 * 重寫Shiro緩存管理器
*/
public class CustomCacheManager implements CacheManager {
    @Override
    public <K, V> Cache<K, V> getCache(String s) throws CacheException {
        return new CustomCache<K,V>();
    }
}

最后在Shiro的配置Bean里設置我們重寫的緩存(Cache)管理器

 

 

 

關於Redis中保存RefreshToken信息(做到JWT的可控性)

登錄認證通過后返回AccessToken信息(在AccessToken中保存當前的時間戳和帳號),同時在Redis中設置一條以帳號為Key,Value為當前時間戳(登錄時間)的RefreshToken,現在認證時必須AccessToken沒失效以及Redis存在所對應的RefreshToken,且RefreshToken時間戳和AccessToken信息中時間戳一致才算認證通過,這樣可以做到JWT的可控性,如果重新登錄獲取了新的AccessToken,舊的AccessToken就認證不了,因為Redis中所存放的的RefreshToken時間戳信息只會和最新的AccessToken信息中攜帶的時間戳一致,這樣每個用戶就只能使用最新的AccessToken認證,Redis的RefreshToken也可以用來判斷用戶是否在線,如果刪除Redis的某個RefreshToken,那這個RefreshToken所對應的AccessToken之后也無法通過認證。

 

關於根據RefreshToken自動刷新AccessToken

本身AccessToken的過期時間為5分鍾(配置文件可配置),RefreshToken過期時間為30分鍾(配置文件可配置),當登錄后時間過了5分鍾之后,當前AccessToken便會過期失效,再次帶上AccessToken訪問JWT會拋出TokenExpiredException異常說明Token過期,開始判斷是否要進行AccessToken刷新,首先Redis查詢RefreshToken是否存在,以及時間戳和過期AccessToken所攜帶的時間戳是否一致,如果存在且一致就進行AccessToken刷新,過期時間為5分鍾(配置文件可配置),時間戳為當前最新時間戳,同時也設置RefreshToken中的時間戳為當前最新時間戳,刷新過期時間重新為30分鍾過期(配置文件可配置),最終將刷新的AccessToken存放在Response的Header中的Authorization字段返

回(前端進行獲取替換,下次用新的AccessToken進行訪問)

測試

先設置Content-Type為application/json

 

 

 

 

然后填寫請求參數帳號密碼信息,進行請求訪問,請求訪問成功

 

 

 

 

點擊查看Header信息的Authorization屬性即是Token字段

 

 

 

訪問需要權限的請求將Token字段放在Header信息的Authorization屬性訪問即可

 

 

 

 

新增用戶也需要權限的請求將Token字段放在Header信息的Authorization屬性訪問即可

 

 

 

 

 

 

最后,目前自己最大問題就是可以理解代碼,但是一旦自己動手做的時候就犯難,過於依賴網上搬磚,業余需要多加聯系。


 [顧祥東1]配置Redis


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM