SpringBoot中使用Shiro和JWT做認證和鑒權


SpringBoot中使用Shiro和JWT做認證和鑒權

一、shiro的幾個基本概念

  1. SecurityManager,控制中心,所有的請求基本上都是通過它來代理轉發的,一般程序不與它打交道

  2. Subject,請求主體,比如登錄用戶,在程序中任何地方都可以通過SecurityUtils.getSubject()獲取到當前的subject。subject可以獲取到principal,這是subject的標識,比如登錄的id用戶名等,shiro不對值做限制,但在登錄授權過程中,程序需要使用principal來識別唯一的用戶。

  3. Realm,可以訪問安全相關數據,提供統一的數據封裝來給上層做數據校驗,shiro的建議是每一個數據源定義一個realm,比如用戶數據存在數據庫可以使用JdbcRealm;存在屬性配置文件可以使用PropertiesRealm。一般我們使用shiro都使用自定義的realm。

    當有多個realm存在的時候,shiro在做用戶校驗的時候會按照定義的策略來決定認證是否通過,shiro提供的可選策略有一個成功或者所有都成功等。

    一個realm對應了一個CredentialsMatcher,用來做用戶提交認證信息和realm獲取得用戶信息做比對,shiro已經提供了常用的比如用戶密碼和存儲的Hash后的密碼的對比。

二、JWT應用

使用帶簽名的token來做用戶和權限驗證,現在流行的公共開放接口用的OAuth 2.0協議基本也是類似的套路

選擇使用jwt不用session的原因:

首先,是要支持多端,一個api要支持H5, PC和APP三個前端,如果使用session的話對app不是很友好,而且session有跨域攻擊的問題。
其次,后端的服務是無狀態的,所以要支持分布式的權限校驗。當然這個不是主要原因了,因為session持久化在spring里面也就是加一行注解就解決的問題。不過,spring通過代理httpsession來做,總歸覺得有點復雜。

三、項目搭建

需求:

  1. 用戶首次通過用戶名密碼登錄
  2. 登陸后通過http header返回token
  3. 每次請求,客戶端需通過header將token帶回,用於權限校驗
  4. 服務端負責token的定期刷新,刷新后新的token仍然放到header中返給客戶端

pom.xml

這里使用了shiro的web starter。jwt是用的auth0的工具包,其實自己實現也比較簡單,我們這里就不自己重新造輪子了。

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

    <groupId>com.github.springboot</groupId>
    <artifactId>shiro-jwt-demo</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>Spring Boot with Shiro and JWT Demo</name>
    <description>Demo project for Spring Boot with Shiro and JWT</description>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.4.RELEASE</version>
    </parent>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
        <shiro.spring.version>1.4.0</shiro.spring.version>
        <jwt.auth0.version>3.2.0</jwt.auth0.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <!-- 使用redis做數據緩存,如果不需要可不依賴 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring-boot-web-starter</artifactId>
            <version>${shiro.spring.version}</version>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>${jwt.auth0.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.5</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.7</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>${java.version}</source>
                    <target>${java.version}</target>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <configuration>
                    <skipTests>true</skipTests>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

shiro配置

ShiroConfiguration

首先是初始化shiro的bean,主要是初始化Realm,注冊Filter,定義filterChain。這些配置的用處后面會逐漸講到。

@Configuration
public class ShiroConfig {
    /**
     * 注冊shiro的Filter,攔截請求
     */
    @Bean
    public FilterRegistrationBean<Filter> filterRegistrationBean(SecurityManager securityManager,UserService userService) throws Exception{
        FilterRegistrationBean<Filter> filterRegistration = new FilterRegistrationBean<Filter>();
        filterRegistration.setFilter((Filter)shiroFilter(securityManager, userService).getObject());
        filterRegistration.addInitParameter("targetFilterLifecycle", "true");
        filterRegistration.setAsyncSupported(true);
        filterRegistration.setEnabled(true);
        filterRegistration.setDispatcherTypes(DispatcherType.REQUEST);

        return filterRegistration;
    }

    /**
     * 初始化Authenticator
     */
    @Bean
    public Authenticator authenticator(UserService userService) {
        ModularRealmAuthenticator authenticator = new ModularRealmAuthenticator();
        //設置兩個Realm,一個用於用戶登錄驗證和訪問權限獲取;一個用於jwt token的認證
        authenticator.setRealms(Arrays.asList(jwtShiroRealm(userService), dbShiroRealm(userService)));
        //設置多個realm認證策略,一個成功即跳過其它的
        authenticator.setAuthenticationStrategy(new FirstSuccessfulStrategy());
        return authenticator;
    }

    /**
    * 禁用session, 不保存用戶登錄狀態。保證每次請求都重新認證。
    * 需要注意的是,如果用戶代碼里調用Subject.getSession()還是可以用session,如果要完全禁用,要配合下面的noSessionCreation的Filter來實現
    */
    @Bean
    protected SessionStorageEvaluator sessionStorageEvaluator(){
        DefaultWebSessionStorageEvaluator sessionStorageEvaluator = new DefaultWebSessionStorageEvaluator();
        sessionStorageEvaluator.setSessionStorageEnabled(false);
        return sessionStorageEvaluator;
    }
    /**
    * 用於用戶名密碼登錄時認證的realm
    */
    @Bean("dbRealm")
    public Realm dbShiroRealm(UserService userService) {
        DbShiroRealm myShiroRealm = new DbShiroRealm(userService);
        return myShiroRealm;
    }
    /**
    * 用於JWT token認證的realm
    */
    @Bean("jwtRealm")
    public Realm jwtShiroRealm(UserService userService) {
        JWTShiroRealm myShiroRealm = new JWTShiroRealm(userService);
        return myShiroRealm;
    }

    /**
     * 設置過濾器,將自定義的Filter加入
     */
    @Bean("shiroFilter")
    public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager, UserService userService) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
        factoryBean.setSecurityManager(securityManager);
        Map<String, Filter> filterMap = factoryBean.getFilters();
        filterMap.put("authcToken", createAuthFilter(userService));
        filterMap.put("anyRole", createRolesFilter());
        factoryBean.setFilters(filterMap);
        factoryBean.setFilterChainDefinitionMap(shiroFilterChainDefinition().getFilterChainMap());

        return factoryBean;
    }

    @Bean
    protected ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        chainDefinition.addPathDefinition("/login", "noSessionCreation,anon");  //login不做認證,noSessionCreation的作用是用戶在操作session時會拋異常
        chainDefinition.addPathDefinition("/logout", "noSessionCreation,authcToken[permissive]"); //做用戶認證,permissive參數的作用是當token無效時也允許請求訪問,不會返回鑒權未通過的錯誤
        chainDefinition.addPathDefinition("/image/**", "anon");
        chainDefinition.addPathDefinition("/admin/**", "noSessionCreation,authcToken,anyRole[admin,manager]"); //只允許admin或manager角色的用戶訪問
        chainDefinition.addPathDefinition("/article/list", "noSessionCreation,authcToken");
        chainDefinition.addPathDefinition("/article/*", "noSessionCreation,authcToken[permissive]");
        chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken"); // 默認進行用戶鑒權
        return chainDefinition;
    }
    //注意不要加@Bean注解,不然spring會自動注冊成filter
    protected JwtAuthFilter createAuthFilter(UserService userService){
        return new JwtAuthFilter(userService);
    }
    //注意不要加@Bean注解,不然spring會自動注冊成filter
    protected AnyRolesAuthorizationFilter createRolesFilter(){
        return new AnyRolesAuthorizationFilter();
    }

}

校驗流程

我們使用Shiro主要做3件事情,

  1. 用戶登錄時做用戶名密碼校驗;
  2. 用戶登錄后收到請求時做JWT Token的校驗;
  3. 用戶權限的校驗

登錄認證流程

登錄controller

從前面的ShiroFilterChainDefinition配置可以看出,對於登錄請求,Filter直接放過,進到controller里面。Controller會調用shiro做用戶名和密碼的校驗,成功后返回token。

@PostMapping(value = "/login")
    public ResponseEntity<Void> login(@RequestBody UserDto loginInfo, HttpServletRequest request, HttpServletResponse response){      
        Subject subject = SecurityUtils.getSubject();
        try {
            //將用戶請求參數封裝后,直接提交給Shiro處理
            UsernamePasswordToken token = new UsernamePasswordToken(loginInfo.getUsername(), loginInfo.getPassword());
            subject.login(token);
            //Shiro認證通過后會將user信息放到subject內,生成token並返回
            UserDto user = (UserDto) subject.getPrincipal();
            String newToken = userService.generateJwtToken(user.getUsername());
            response.setHeader("x-auth-token", newToken);
            
            return ResponseEntity.ok().build();
        } catch (AuthenticationException e) { 
           // 如果校驗失敗,shiro會拋出異常,返回客戶端失敗
            logger.error("User {} login fail, Reason:{}", loginInfo.getUsername(), e.getMessage());
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

登錄的Realm

從上面的controller實現我們看到,controller只負責封裝下參數,然后扔給Shiro了,這時候Shiro收到后,會到所有的realm中找能處理UsernamePasswordToken的Realm(我們這里是DbShiroRealm),然后交給Realm處理。Realm的實現一般直接繼承AuthorizingRealm即可,只需要實現兩個方法,doGetAuthenticationInfo()會在用戶驗證時被調用,我們看下實現:

public class DbShiroRealm extends AuthorizingRealm {
    //數據庫存儲的用戶密碼的加密salt,正式環境不能放在源代碼里
    private static final String encryptSalt = "F12839WhsnnEV$#23b";
    private UserService userService;
    
    public DbShiroRealm(UserService userService) {
        this.userService = userService;
        //因為數據庫中的密碼做了散列,所以使用shiro的散列Matcher
        this.setCredentialsMatcher(new HashedCredentialsMatcher(Sha256Hash.ALGORITHM_NAME));
    }
    /**
     *  找它的原因是這個方法返回true
     */ 
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof UsernamePasswordToken;
    }
   /**
    *  這一步我們根據token給的用戶名,去數據庫查出加密過用戶密碼,然后把加密后的密碼和鹽值一起發給shiro,讓它做比對
    */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken userpasswordToken = (UsernamePasswordToken)token;
        String username = userpasswordToken.getUsername();
        UserDto user = userService.getUserInfo(username);
        if(user == null)
            throw new AuthenticationException("用戶名或者密碼錯誤");
        
        return new SimpleAuthenticationInfo(user, user.getEncryptPwd(), ByteSource.Util.bytes(encryptSalt), "dbRealm");
    }

}

我們可以看到doGetAuthenticationInfo里面只判斷了用戶存不存在,其實也沒做密碼比對,只是把數據庫的數據封裝一下就返回了。真正的比對邏輯在Matcher里實現的,這個shiro已經替我們實現了。如果matcher返回false,shiro會拋出異常,這樣controller那邊就會知道驗證失敗了。

登出

登出操作就比較簡單了,我們只需要把用戶登錄后保存的salt值清除,然后調用shiro的logout就可以了,shiro會將剩下的事情做完。

    @GetMapping(value = "/logout")
    public ResponseEntity<Void> logout() {
        Subject subject = SecurityUtils.getSubject();
        if(subject.getPrincipals() != null) {
            UserDto user = (UserDto)subject.getPrincipals().getPrimaryPrincipal();
            userService.deleteLoginInfo(user.getUsername());
        }
        SecurityUtils.getSubject().logout();
        return ResponseEntity.ok().build();
    }

這樣整個登錄/登出就結束了,我們可以看到shiro對整個邏輯的拆解還是比較清楚的,各個模塊各司其職。

請求認證模塊

請求認證的流程其實和登錄認證流程是比較相似的,因為我們的服務是無狀態的,所以每次請求帶來token,我們就是做了一次登錄操作。

JwtAuthFilter

首先我們先從入口的Filter開始。從AuthenticatingFilter繼承,重寫isAccessAllow方法,方法中調用父類executeLogin()。父類的這個方法首先會createToken(),然后調用shiro的Subject.login()方法。是不是跟LoginController中的邏輯很像。

public class JwtAuthFilter extends AuthenticatingFilter {
    /**
     * 父類會在請求進入攔截器后調用該方法,返回true則繼續,返回false則會調用onAccessDenied()。這里在不通過時,還調用了isPermissive()方法,我們后面解釋。
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        if(this.isLoginRequest(request, response))
            return true;
        boolean allowed = false;
        try {
            allowed = executeLogin(request, response);
        } catch(IllegalStateException e){ //not found any token
            log.error("Not found any token");
        }catch (Exception e) {
            log.error("Error occurs when login", e);
        }
        return allowed || super.isPermissive(mappedValue);
    }
    /**
     * 這里重寫了父類的方法,使用我們自己定義的Token類,提交給shiro。這個方法返回null的話會直接拋出異常,進入isAccessAllowed()的異常處理邏輯。
     */
    @Override
    protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) {
        String jwtToken = getAuthzHeader(servletRequest);
        if(StringUtils.isNotBlank(jwtToken)&&!JwtUtils.isTokenExpired(jwtToken))
            return new JWTToken(jwtToken);

        return null;
    }
    /**
      * 如果這個Filter在之前isAccessAllowed()方法中返回false,則會進入這個方法。我們這里直接返回錯誤的response
      */
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletResponse httpResponse = WebUtils.toHttp(servletResponse);
        httpResponse.setCharacterEncoding("UTF-8");
        httpResponse.setContentType("application/json;charset=UTF-8");
        httpResponse.setStatus(HttpStatus.SC_NON_AUTHORITATIVE_INFORMATION);
        fillCorsHeader(WebUtils.toHttp(servletRequest), httpResponse);
        return false;
    }
    /**
     *  如果Shiro Login認證成功,會進入該方法,等同於用戶名密碼登錄成功,我們這里還判斷了是否要刷新Token
     */
    @Override
    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
        HttpServletResponse httpResponse = WebUtils.toHttp(response);
        String newToken = null;
        if(token instanceof JWTToken){
            JWTToken jwtToken = (JWTToken)token;
            UserDto user = (UserDto) subject.getPrincipal();
            boolean shouldRefresh = shouldTokenRefresh(JwtUtils.getIssuedAt(jwtToken.getToken()));
            if(shouldRefresh) {
                newToken = userService.generateJwtToken(user.getUsername());
            }
        }
        if(StringUtils.isNotBlank(newToken))
            httpResponse.setHeader("x-auth-token", newToken);

        return true;
    }
    /**
      * 如果調用shiro的login認證失敗,會回調這個方法,這里我們什么都不做,因為邏輯放到了onAccessDenied()中。
      */
    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
        log.error("Validate token fail, token:{}, error:{}", token.toString(), e.getMessage());
        return false;
    }
}

JWT token封裝

在上面的Filter中我們創建了一個Token提交給了shiro,我們看下這個Token,其實很簡單,就是把jwt的token放在里面。

public class JWTToken implements HostAuthenticationToken {
    private String token;
    private String host;
    public JWTToken(String token) {
        this(token, null);
    }
    public JWTToken(String token, String host) {
        this.token = token;
        this.host = host;
    }
    public String getToken(){
        return this.token;
    }
    public String getHost() {
        return host;
    }
    @Override
    public Object getPrincipal() {
        return token;
    }
    @Override
    public Object getCredentials() {
        return token;
    }
    @Override
    public String toString(){
        return token + ':' + host;
    }
}

JWT Realm

Token有了,filter中也調用了shiro的login()方法了,下一步自然是Shiro把token提交到Realm中,獲取存儲的認證信息來做比對。

public class JWTShiroRealm extends AuthorizingRealm {
    protected UserService userService;

    public JWTShiroRealm(UserService userService){
        this.userService = userService;
        //這里使用我們自定義的Matcher
        this.setCredentialsMatcher(new JWTCredentialsMatcher());
    }
    /**
     * 限定這個Realm只支持我們自定義的JWT Token
    */ 
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JWTToken;
    }

    /**
     * 更controller登錄一樣,也是獲取用戶的salt值,給到shiro,由shiro來調用matcher來做認證
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) throws AuthenticationException {
        JWTToken jwtToken = (JWTToken) authcToken;
        String token = jwtToken.getToken();
        
        UserDto user = userService.getJwtTokenInfo(JwtUtils.getUsername(token));
        if(user == null)
            throw new AuthenticationException("token過期,請重新登錄");

        SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user.getUsername(), user.getSalt(), "jwtRealm");

        return authenticationInfo;
    }
}

JWT Matcher

跟controller登錄不一樣,shiro並沒有實現JWT的Matcher,需要我們自己來實現。代碼如下:

public class JWTCredentialsMatcher implements CredentialsMatcher {
    /**
     * Matcher中直接調用工具包中的verify方法即可
     */
    @Override
    public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
        String token = (String) authenticationToken.getCredentials();
        Object stored = authenticationInfo.getCredentials();
        String salt = stored.toString();

        UserDto user = (UserDto)authenticationInfo.getPrincipals().getPrimaryPrincipal();
        try {
            Algorithm algorithm = Algorithm.HMAC256(salt);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("username", user.getUsername())
                    .build();
            verifier.verify(token);
            return true;
        } catch (UnsupportedEncodingException | JWTVerificationException e) {
            log.error("Token Error:{}", e.getMessage());
        }
        return false;
    }
}

這樣非登錄請求的認證處理邏輯也結束了,看起來是不是跟登錄邏輯差不多。其實對於無狀態服務來說,每次請求都相當於做了一次登錄操作,我們用session的時候之所以不需要做,是因為容器代替我們把這件事干掉了。

關於permissive

前面Filter里面的isAccessAllow方法,除了使用jwt token做了shiro的登錄認證之外,如果返回false還會額外調用isPermissive()方法。這里面干了什么呢?我們看下父類的方法:

    /**
     * Returns <code>true</code> if the mappedValue contains the {@link #PERMISSIVE} qualifier.
     *
     * @return <code>true</code> if this filter should be permissive
     */
    protected boolean isPermissive(Object mappedValue) {
        if(mappedValue != null) {
            String[] values = (String[]) mappedValue;
            return Arrays.binarySearch(values, PERMISSIVE) >= 0;
        }
        return false;
    }

邏輯很簡單,如果filter的攔截配置那里配置了permissive參數,即使登錄認證沒通過,因為isPermissive返回true,還是會讓請求繼續下去的。細心的同學或許已經發現我們之前shiroConfig里面的配置了,截取過來看一下:

chainDefinition.addPathDefinition("/logout", "noSessionCreation,authcToken[permissive]"); //做用戶認證,permissive參數的作用是當token無效時也允許請求訪問,不會返回鑒權未通過的錯誤

就是這么簡單直接,字符串匹配。當然這里也可以重寫這個方法插入更復雜的邏輯。
這么做的目的是什么呢?因為有時候我們對待請求,並不都是非黑即白,比如登出操作,如果用戶帶的token是正確的,我們會將保存的用戶信息清除;如果帶的token是錯的,也沒關系,大不了不干啥,沒必要返回錯誤給用戶。還有一個典型的案例,比如我們閱讀博客,匿名用戶也是可以看的。只是如果是登錄用戶,我們會顯示額外的東西,比如是不是點過贊等。所以認證這里的邏輯就是token是對的,我會給把人認出來;是錯的,我也直接放過,留給controller來決定怎么區別對待。

JWT Token刷新

前面的Filter里面還有一個邏輯,就是如果用戶這次的token校驗通過后,我們還會順便看看token要不要刷新,如果需要刷新則將新的token放到header里面。
這樣做的目的是防止token丟了之后,別人可以拿着一直用。我們這里是固定時間刷新。安全性要求更高的系統可能每次請求都要求刷新,或者是每次POST,PUT等修改數據的請求后必須刷新。判斷邏輯如下:

protected boolean shouldTokenRefresh(Date issueAt){
        LocalDateTime issueTime = LocalDateTime.ofInstant(issueAt.toInstant(), ZoneId.systemDefault());
        return LocalDateTime.now().minusSeconds(tokenRefreshInterval).isAfter(issueTime);
    }

以上就是jwt token校驗的所有邏輯了,是不是有點繞,畫一個流程圖出來,對比着看應該更清楚一點。

角色配置

認證講完了,下面看下訪問控制。對於角色檢查的攔截,是通過繼承一個AuthorizationFilter的Filter來實現的。Shiro提供了一個默認的實現RolesAuthorizationFilter,比如可以這么配置:

chainDefinition.addPathDefinition("/article/edit", "authc,role[admin]");

表示要做文章的edit操作,需要滿足兩個條件,首先authc表示要通過用戶認證,這個我們上面已經講過了;其次要具備admin的角色。shiro是怎么做的呢?就是在請求進入這個filter后,shiro會調用所有配置的Realm獲取用戶的角色信息,然后和Filter中配置的角色做對比,對上了就可以通過了。
所以我們所有的Realm還要另外一個方法doGetAuthorizationInfo,不得不吐槽一下,realm里面要實現的這兩個方法的名字實在太像了。
在JWT Realm里面,因為沒有存儲角色信息,所以直接返回空就可以了:

@Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return new SimpleAuthorizationInfo();
    }

在DbRealm里面,實現如下:

@Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        UserDto user = (UserDto) principals.getPrimaryPrincipal();
        List<String> roles = user.getRoles();
        if(roles == null) {
            roles = userService.getUserRoles(user.getUserId());
            user.setRoles(roles);
        }
        if (roles != null)
            simpleAuthorizationInfo.addRoles(roles);

        return simpleAuthorizationInfo;
    }

這里需要注意一下的就是Shiro默認不會緩存角色信息,所以這里調用service的方法獲取角色強烈建議從緩存中獲取。

自己實現RoleFilter

在實際的項目中,對同一個url多個角色都有訪問權限很常見,shiro默認的RoleFilter沒有提供支持,比如上面的配置,如果我們配置成下面這樣,那用戶必須同時具備admin和manager權限才能訪問,顯然這個是不合理的。

chainDefinition.addPathDefinition("/admin/**", "authc,role[admin,manager]");

所以自己實現一個role filter,只要任何一個角色符合條件就通過,只需要重寫AuthorizationFilter中兩個方法就可以了:

public class AnyRolesAuthorizationFilter  extends AuthorizationFilter {

    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object mappedValue) throws Exception {
        Subject subject = getSubject(servletRequest, servletResponse);
        String[] rolesArray = (String[]) mappedValue;
        if (rolesArray == null || rolesArray.length == 0) { //沒有角色限制,有權限訪問
            return true;
        }
        for (String role : rolesArray) {
            if (subject.hasRole(role)) //若當前用戶是rolesArray中的任何一個,則有權限訪問
                return true;
        }
        return false;
    }
    /**
     * 權限校驗失敗,錯誤處理
    */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
        HttpServletResponse httpResponse = WebUtils.toHttp(response);
        httpResponse.setCharacterEncoding("UTF-8");
        httpResponse.setContentType("application/json;charset=utf-8");
        httpResponse.setStatus(HttpStatus.SC_UNAUTHORIZED);
        return false;
    }

}

禁用session

因為用了jwt的訪問認證,所以要把默認session支持關掉。這里要做兩件事情,一個是ShiroConfig里面的配置:

   @Bean
    protected SessionStorageEvaluator sessionStorageEvaluator(){
        DefaultWebSessionStorageEvaluator sessionStorageEvaluator = new DefaultWebSessionStorageEvaluator();
        sessionStorageEvaluator.setSessionStorageEnabled(false);
        return sessionStorageEvaluator;
    }

另外一個是在對請求加上noSessionCreationFilter,具體原因上面的代碼中已經有解釋,用法如下:

chainDefinition.addPathDefinition("/**", "noSessionCreation,authcToken");

跨域支持

對於前后端分離的項目,一般都需要跨域訪問,這里需要做兩件事,一個是在JwtFilter的postHandle中在頭上加上跨域支持的選項(理論上應該重新定義一個Filter的,圖省事就讓它多干點吧)。

@Override
    protected void postHandle(ServletRequest request, ServletResponse response){
        this.fillCorsHeader(WebUtils.toHttp(request), WebUtils.toHttp(response));
    }

在實際使用中發現,對於controller返回@ResponseBody的請求,filter中添加的header信息會丟失。對於這個問題spring已經給出解釋,並建議實現ResponseBodyAdvice類,並添加@ControllerAdvice。

所以如果存在返回@ResponseBody的controller,需要添加一個ResponseBodyAdvice實現類

@ControllerAdvice
public class ResponseHeaderAdvice implements ResponseBodyAdvice<Object> {
    @Override
    public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass,
                                  ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
        ServletServerHttpRequest serverRequest = (ServletServerHttpRequest)serverHttpRequest;
        ServletServerHttpResponse serverResponse = (ServletServerHttpResponse)serverHttpResponse;
        if(serverRequest == null || serverResponse == null
                || serverRequest.getServletRequest() == null || serverResponse.getServletResponse() == null) {
            return o;
        }

        // 對於未添加跨域消息頭的響應進行處理
        HttpServletRequest request = serverRequest.getServletRequest();
        HttpServletResponse response = serverResponse.getServletResponse();
        String originHeader = "Access-Control-Allow-Origin";
        if(!response.containsHeader(originHeader)) {
            String origin = request.getHeader("Origin");
            if(origin == null) {
                String referer = request.getHeader("Referer");
                if(referer != null)
                    origin = referer.substring(0, referer.indexOf("/", 7));
            }
            response.setHeader("Access-Control-Allow-Origin", origin);
        }

        String allowHeaders = "Access-Control-Allow-Headers";
        if(!response.containsHeader(allowHeaders))
            response.setHeader(allowHeaders, request.getHeader(allowHeaders));

        String allowMethods = "Access-Control-Allow-Methods";
        if(!response.containsHeader(allowMethods))
            response.setHeader(allowMethods, "GET,POST,OPTIONS,HEAD");
        //這個很關鍵,要不然ajax調用時瀏覽器默認不會把這個token的頭屬性返給JS
        String exposeHeaders = "access-control-expose-headers";
        if(!response.containsHeader(exposeHeaders))
            response.setHeader(exposeHeaders, "x-auth-token");

        return o;
    }
}

好了,到這里使用shiro和jwt做用戶認證和鑒權的實現就結束了

代碼詳細地址:https://www.jianshu.com/p/0b1131be7ace


免責聲明!

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



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