Shiro用starter方式優雅整合到SpringBoot中

Shiro用starter方式優雅整合到SpringBoot中

網上找到大部分文章都是之前SpringMVC下的整合方式,不少人都不知道shiro提供了官方的starter能夠方便地跟SpringBoot整合。本文介紹個人3種整合思路:1.徹底使用註解;2.徹底使用url配置;3.url配置和註解混用,url配置負責鑑權控制,註解負責權限控制。三種方式各有優劣,需考慮實際應用場景使用。

代碼

Talk is cheap, show you my code: elegant-shiro-boot
這個工程使用gradle構建,有三個子工程:html

  • demo1演示只用註解來作鑑權受權
  • demo2演示只用url配置來作鑑權受權
  • demo3演示兩種方式結合,url配置負責控制鑑權,註解配置負責控制受權。

如何整合

請看shiro官網關於springboot整合shiro的連接:Integrating Apache Shiro into Spring-Boot Applicationsjava

好笑的是,我本身直接上去官網找,找來找去都找不到這一頁的文檔,而是經過google找出來的。

這篇文檔的介紹也至關簡單。咱們只須要按照文檔說明,引入shiro-spring-boot-starter,而後在spring容器中注入一個咱們自定義的Realm,shiro經過這個realm就能夠知道如何獲取用戶信息來處理鑑權(Authentication),如何獲取用戶角色、權限信息來處理受權(Authorization)python

ps:鑑權能夠理解成判斷一個用戶是否已登陸的過程,受權能夠理解成判斷一個已登陸用戶是否有訪問權限的過程。

整合過程:
1.引入starter,個人是用gradle作項目構建的,maven也是引入對應的依賴便可:c++

dependencies {
    //spring boot的starter
    compile 'org.springframework.boot:spring-boot-starter-web'
    compile 'org.springframework.boot:spring-boot-starter-aop'
    compile 'org.springframework.boot:spring-boot-devtools'
    testCompile 'org.springframework.boot:spring-boot-starter-test'
    //shiro
    compile 'org.apache.shiro:shiro-spring-boot-web-starter:1.4.0'
}

2.編寫自定義realmgit

User.java(其它RBAC模型請看github上的代碼com.abc.entity包下的類)程序員

public class User {

    private Long uid;       // 用戶id
    private String uname;   // 登陸名,不可改
    private String nick;    // 用戶暱稱,可改
    private String pwd;     // 已加密的登陸密碼
    private String salt;    // 加密鹽值
    private Date created;   // 建立時間
    private Date updated;   // 修改時間
    private Set<String> roles = new HashSet<>();    //用戶全部角色值,用於shiro作角色權限的判斷
    private Set<String> perms = new HashSet<>();    //用戶全部權限值,用於shiro作資源權限的判斷
    //getters and setters...
}

UserService.javagithub

@Service
public class UserService {

    /**
     * 模擬查詢返回用戶信息
     * @param uname
     * @return
     */
    public User findUserByName(String uname){
        User user = new User();
        user.setUname(uname);
        user.setNick(uname+"NICK");
        user.setPwd("J/ms7qTJtqmysekuY8/v1TAS+VKqXdH5sB7ulXZOWho=");//密碼明文是123456
        user.setSalt("wxKYXuTPST5SG0jMQzVPsg==");//加密密碼的鹽值
        user.setUid(new Random().nextLong());//隨機分配一個id
        user.setCreated(new Date());
        return user;
    }
}

RoleService.javaweb

@Service
public class RoleService {

    /**
     * 模擬根據用戶id查詢返回用戶的全部角色,實際查詢語句參考:
     * SELECT r.rval FROM role r, user_role ur
     * WHERE r.rid = ur.role_id AND ur.user_id = #{userId}
     * @param uid
     * @return
     */
    public Set<String> getRolesByUserId(Long uid){
        Set<String> roles = new HashSet<>();
        //三種編程語言表明三種角色:js程序員、java程序員、c++程序員
        roles.add("js");
        roles.add("java");
        roles.add("cpp");
        return roles;
    }

}

PermService.javaspring

@Service
public class PermService {

    /**
     * 模擬根據用戶id查詢返回用戶的全部權限,實際查詢語句參考:
     * SELECT p.pval FROM perm p, role_perm rp, user_role ur
     * WHERE p.pid = rp.perm_id AND ur.role_id = rp.role_id
     * AND ur.user_id = #{userId}
     * @param uid
     * @return
     */
    public Set<String> getPermsByUserId(Long uid){
        Set<String> perms = new HashSet<>();
        //三種編程語言表明三種角色:js程序員、java程序員、c++程序員
        //js程序員的權限
        perms.add("html:edit");
        //c++程序員的權限
        perms.add("hardware:debug");
        //java程序員的權限
        perms.add("mvn:install");
        perms.add("mvn:clean");
        perms.add("mvn:test");
        return perms;
    }

}

CustomRealm.javaapache

/**
 * 這個類是參照JDBCRealm寫的,主要是自定義瞭如何查詢用戶信息,如何查詢用戶的角色和權限,如何校驗密碼等邏輯
 */
public class CustomRealm extends AuthorizingRealm {

    @Autowired
    private UserService userService;
    @Autowired
    private RoleService roleService;
    @Autowired
    private PermService permService;

    //告訴shiro如何根據獲取到的用戶信息中的密碼和鹽值來校驗密碼
    {
        //設置用於匹配密碼的CredentialsMatcher
        HashedCredentialsMatcher hashMatcher = new HashedCredentialsMatcher();
        hashMatcher.setHashAlgorithmName(Sha256Hash.ALGORITHM_NAME);
        hashMatcher.setStoredCredentialsHexEncoded(false);
        hashMatcher.setHashIterations(1024);
        this.setCredentialsMatcher(hashMatcher);
    }


    //定義如何獲取用戶的角色和權限的邏輯,給shiro作權限判斷
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        //null usernames are invalid
        if (principals == null) {
            throw new AuthorizationException("PrincipalCollection method argument cannot be null.");
        }

        User user = (User) getAvailablePrincipal(principals);

        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        System.out.println("獲取角色信息:"+user.getRoles());
        System.out.println("獲取權限信息:"+user.getPerms());
        info.setRoles(user.getRoles());
        info.setStringPermissions(user.getPerms());
        return info;
    }

    //定義如何獲取用戶信息的業務邏輯,給shiro作登陸
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        String username = upToken.getUsername();

        // Null username is invalid
        if (username == null) {
            throw new AccountException("Null usernames are not allowed by this realm.");
        }

        User userDB = userService.findUserByName(username);


        if (userDB == null) {
            throw new UnknownAccountException("No account found for admin [" + username + "]");
        }

        //查詢用戶的角色和權限存到SimpleAuthenticationInfo中,這樣在其它地方
        //SecurityUtils.getSubject().getPrincipal()就能拿出用戶的全部信息,包括角色和權限
        Set<String> roles = roleService.getRolesByUserId(userDB.getUid());
        Set<String> perms = permService.getPermsByUserId(userDB.getUid());
        userDB.getRoles().addAll(roles);
        userDB.getPerms().addAll(perms);

        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(userDB, userDB.getPwd(), getName());
        if (userDB.getSalt() != null) {
            info.setCredentialsSalt(ByteSource.Util.bytes(userDB.getSalt()));
        }

        return info;

    }

}

3.使用註解或url配置,來控制鑑權受權

請參照官網的示例:

//url配置
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
    DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
    
    // logged in users with the 'admin' role
    chainDefinition.addPathDefinition("/admin/**", "authc, roles[admin]");
    
    // logged in users with the 'document:read' permission
    chainDefinition.addPathDefinition("/docs/**", "authc, perms[document:read]");
    
    // all other paths require a logged in user
    chainDefinition.addPathDefinition("/**", "authc");
    return chainDefinition;
}
//註解配置
@RequiresPermissions("document:read")
public void readDocument() {
    ...
}

4.解決spring aop和註解配置一塊兒使用的bug。若是您在使用shiro註解配置的同時,引入了spring aop的starter,會有一個奇怪的問題,致使shiro註解的請求,不能被映射,需加入如下配置:

@Bean
    public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator(){
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator=new DefaultAdvisorAutoProxyCreator();
        /**
         * setUsePrefix(false)用於解決一個奇怪的bug。在引入spring aop的狀況下。
         * 在@Controller註解的類的方法中加入@RequiresRole等shiro註解,會致使該方法沒法映射請求,致使返回404。
         * 加入這項配置能解決這個bug
         */
        defaultAdvisorAutoProxyCreator.setUsePrefix(true);
        return defaultAdvisorAutoProxyCreator;
    }

思路1:只用註解控制鑑權受權

使用註解的優勢是控制的粒度細,而且很是適合用來作基於資源的權限控制。

關於基於資源的權限控制,建議看看這篇文章: The New RBAC: Resource-Based Access Control

只用註解的話很是簡單。咱們只須要使用url配置配置一下因此請求路徑均可以匿名訪問:

//在 ShiroConfig.java 中的代碼:
    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chain = new DefaultShiroFilterChainDefinition();
        // 因爲demo1展現統一使用註解作訪問控制,因此這裏配置全部請求路徑均可以匿名訪問
        chain.addPathDefinition("/**", "anon"); // all paths are managed via annotations

        // 這另外一種配置方式。可是仍是用上面那種吧,容易理解一點。
        // or allow basic authentication, but NOT require it.
        // chainDefinition.addPathDefinition("/**", "authcBasic[permissive]");
        return chain;
    }

而後在控制器類上使用shiro提供的種註解來作控制:

註解 功能
@RequiresGuest 只有遊客能夠訪問
@RequiresAuthentication 須要登陸才能訪問
@RequiresUser 已登陸的用戶或「記住我」的用戶能訪問
@RequiresRoles 已登陸的用戶需具備指定的角色才能訪問
@RequiresPermissions 已登陸的用戶需具備指定的權限才能訪問

代碼示例:(更詳細的請參考github代碼的demo1)

/**
 * created by CaiBaoHong at 2018/4/18 15:51<br>
 *     測試shiro提供的註解及功能解釋
 */
@RestController
@RequestMapping("/t1")
public class Test1Controller {

    // 因爲TestController類上沒有加@RequiresAuthentication註解,
    // 不要求用戶登陸才能調用接口。因此hello()和a1()接口都是能夠匿名訪問的
    @GetMapping("/hello")
    public String hello() {
        return "hello spring boot";
    }

    // 遊客可訪問,這個有點坑,遊客的意思是指:subject.getPrincipal()==null
    // 因此用戶在未登陸時subject.getPrincipal()==null,接口可訪問
    // 而用戶登陸後subject.getPrincipal()!=null,接口不可訪問
    @RequiresGuest
    @GetMapping("/guest")
    public String guest() {
        return "@RequiresGuest";
    }

    // 已登陸用戶才能訪問,這個註解比@RequiresUser更嚴格
    // 若是用戶未登陸調用該接口,會拋出UnauthenticatedException
    @RequiresAuthentication
    @GetMapping("/authn")
    public String authn() {
        return "@RequiresAuthentication";
    }

    // 已登陸用戶或「記住我」的用戶能夠訪問
    // 若是用戶未登陸或不是「記住我」的用戶調用該接口,UnauthenticatedException
    @RequiresUser
    @GetMapping("/user")
    public String user() {
        return "@RequiresUser";
    }

    // 要求登陸的用戶具備mvn:build權限才能訪問
    // 因爲UserService模擬返回的用戶信息中有該權限,因此這個接口能夠訪問
    // 若是沒有登陸,UnauthenticatedException
    @RequiresPermissions("mvn:install")
    @GetMapping("/mvnInstall")
    public String mvnInstall() {
        return "mvn:install";
    }

    // 要求登陸的用戶具備mvn:build權限才能訪問
    // 因爲UserService模擬返回的用戶信息中【沒有】該權限,因此這個接口【不能夠】訪問
    // 若是沒有登陸,UnauthenticatedException
    // 若是登陸了,可是沒有這個權限,會報錯UnauthorizedException
    @RequiresPermissions("gradleBuild")
    @GetMapping("/gradleBuild")
    public String gradleBuild() {
        return "gradleBuild";
    }

    // 要求登陸的用戶具備js角色才能訪問
    // 因爲UserService模擬返回的用戶信息中有該角色,因此這個接口可訪問
    // 若是沒有登陸,UnauthenticatedException
    @RequiresRoles("js")
    @GetMapping("/js")
    public String js() {
        return "js programmer";
    }

    // 要求登陸的用戶具備js角色才能訪問
    // 因爲UserService模擬返回的用戶信息中有該角色,因此這個接口可訪問
    // 若是沒有登陸,UnauthenticatedException
    // 若是登陸了,可是沒有該角色,會拋出UnauthorizedException
    @RequiresRoles("python")
    @GetMapping("/python")
    public String python() {
        return "python programmer";
    }

}

思路2:只用url配置控制鑑權受權

shiro提供和多個默認的過濾器,咱們能夠用這些過濾器來配置控制指定url的權限:

配置縮寫 對應的過濾器 功能
anon AnonymousFilter 指定url能夠匿名訪問
authc FormAuthenticationFilter 指定url須要form表單登陸,默認會從請求中獲取usernamepassword,rememberMe等參數並嘗試登陸,若是登陸不了就會跳轉到loginUrl配置的路徑。咱們也能夠用這個過濾器作默認的登陸邏輯,可是通常都是咱們本身在控制器寫登陸邏輯的,本身寫的話出錯返回的信息均可以定製嘛。
authcBasic BasicHttpAuthenticationFilter 指定url須要basic登陸
logout LogoutFilter 登出過濾器,配置指定url就能夠實現退出功能,很是方便
noSessionCreation NoSessionCreationFilter 禁止建立會話
perms PermissionsAuthorizationFilter 須要指定權限才能訪問
port PortFilter 須要指定端口才能訪問
rest HttpMethodPermissionFilter 將http請求方法轉化成相應的動詞來構造一個權限字符串,這個感受意義不大,有興趣本身看源碼的註釋
roles RolesAuthorizationFilter 須要指定角色才能訪問
ssl SslFilter 須要https請求才能訪問
user UserFilter 須要已登陸或「記住我」的用戶才能訪問

在spring容器中使用ShiroFilterChainDefinition來控制全部url的鑑權和受權。優勢是配置粒度大,對多個Controller作鑑權受權的控制。下面是例子,具體能夠看github代碼的demo2:

@Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chain = new DefaultShiroFilterChainDefinition();

        /**
         * 這裏當心踩坑!我在application.yml中設置的context-path: /api/v1
         * 但通過實際測試,過濾器的過濾路徑,是context-path下的路徑,無需加上"/api/v1"前綴
         */

        //訪問控制
        chain.addPathDefinition("/user/login", "anon");//能夠匿名訪問
        chain.addPathDefinition("/page/401", "anon");//能夠匿名訪問
        chain.addPathDefinition("/page/403", "anon");//能夠匿名訪問
        chain.addPathDefinition("/t4/hello", "anon");//能夠匿名訪問

        chain.addPathDefinition("/t4/changePwd", "authc");//須要登陸
        chain.addPathDefinition("/t4/user", "user");//已登陸或「記住我」的用戶能夠訪問
        chain.addPathDefinition("/t4/mvnBuild", "authc,perms[mvn:install]");//須要mvn:build權限
        chain.addPathDefinition("/t4/gradleBuild", "authc,perms[gradle:build]");//須要gradle:build權限
        chain.addPathDefinition("/t4/js", "authc,roles[js]");//須要js角色
        chain.addPathDefinition("/t4/python", "authc,roles[python]");//須要python角色

        // shiro 提供的登出過濾器,訪問指定的請求,就會執行登陸,默認跳轉路徑是"/",或者是"shiro.loginUrl"配置的內容
        // 因爲application-shiro.yml中配置了 shiro:loginUrl: /page/401,返回會返回對應的json內容
        // 能夠結合/user/login和/t1/js接口來測試這個/t4/logout接口是否有效
        chain.addPathDefinition("/t4/logout", "anon,logout");

        //其它路徑均須要登陸
        chain.addPathDefinition("/**", "authc");

        return chain;
    }

思路3:兩者結合,url配置控制鑑權,註解控制受權

就我的而言,我是很是喜歡註解方式的。可是兩種配置方式靈活結合,纔是適應不一樣應用場景的最佳實踐。只用註解或只用url配置,會帶來一些比較累的工做。

我舉兩個場景:
場景1
假如我是寫系統後臺管理系統的,並且個人java後臺是一個純粹返回json數據的後臺,不會作頁面跳轉的工做。那咱們後臺管理系統通常都是所有接口都須要登陸才能訪問。若是隻用註解,我須要在每一個Controller上加上@RequiresAuthentication來聲明每一個Controller下每一個方法都須要登陸才能訪問。這樣顯得有點麻煩,並且往後再加Controller,仍是要加上這個註解,萬一忘記加了就會出錯。這時候其實用url配置的方式就能夠配置所有請求都須要登陸才能訪問:chain.addPathDefinition("/**", "authc");

場景2
假如我是寫商城的前臺的,並且個人java後臺是一個純粹返回json數據的後臺,可是這些接接口中,在同一個Controller下,有些是能夠匿名訪問的,有些是須要登陸才能訪問的,有些是須要特定角色、權限才能訪問的。若是隻用url配置,每一個url都須要配置,並且容易配置錯,粒度很差把控。

因此個人想法是:用url配置控制鑑權,實現粗粒度控制;用註解控制受權,實現細粒度控制

下面是示例代碼(詳細的請看github代碼的demo3):
ShiroConfig.java

@Configuration
public class ShiroConfig {

    //注入自定義的realm,告訴shiro如何獲取用戶信息來作登陸或權限控制
    @Bean
    public Realm realm() {
        return new CustomRealm();
    }

    @Bean
    public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
        /**
         * setUsePrefix(false)用於解決一個奇怪的bug。在引入spring aop的狀況下。
         * 在@Controller註解的類的方法中加入@RequiresRole註解,會致使該方法沒法映射請求,致使返回404。
         * 加入這項配置能解決這個bug
         */
        creator.setUsePrefix(true);
        return creator;
    }

    /**
     * 這裏統一作鑑權,即判斷哪些請求路徑須要用戶登陸,哪些請求路徑不須要用戶登陸。
     * 這裏只作鑑權,不作權限控制,由於權限用註解來作。
     * @return
     */
    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chain = new DefaultShiroFilterChainDefinition();
        //哪些請求能夠匿名訪問
        chain.addPathDefinition("/user/login", "anon");
        chain.addPathDefinition("/page/401", "anon");
        chain.addPathDefinition("/page/403", "anon");
        chain.addPathDefinition("/t5/hello", "anon");
        chain.addPathDefinition("/t5/guest", "anon");

        //除了以上的請求外,其它請求都須要登陸
        chain.addPathDefinition("/**", "authc");
        return chain;
    }
}

PageController.java

@RestController
@RequestMapping("/page")
public class PageController {

    // shiro.loginUrl映射到這裏,我在這裏直接拋出異常交給GlobalExceptionHandler來統一返回json信息,
    // 您也能夠在這裏json,不過這樣子就跟GlobalExceptionHandler中返回的json重複了。
    @RequestMapping("/401")
    public Json page401() {
        throw new UnauthenticatedException();
    }

    // shiro.unauthorizedUrl映射到這裏。因爲demo3統一約定了url方式只作鑑權控制,不作權限訪問控制,
    // 也就是說在ShiroConfig中若是沒有roles[js],perms[mvn:install]這樣的權限訪問控制配置的話,
    // 是不會跳轉到這裏的。
    @RequestMapping("/403")
    public Json page403() {
        throw new UnauthorizedException();
    }

    @RequestMapping("/index")
    public Json pageIndex() {
        return new Json("index",true,1,"index page",null);
    }


}

GlobalExceptionHandler.java

/**
 * 統一捕捉shiro的異常,返回給前臺一個json信息,前臺根據這個信息顯示對應的提示,或者作頁面的跳轉。
 */
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    //不知足@RequiresGuest註解時拋出的異常信息
    private static final String GUEST_ONLY = "Attempting to perform a guest-only operation";


    @ExceptionHandler(ShiroException.class)
    @ResponseBody
    public Json handleShiroException(ShiroException e) {
        String eName = e.getClass().getSimpleName();
        log.error("shiro執行出錯:{}",eName);
        return new Json(eName, false, Codes.SHIRO_ERR, "鑑權或受權過程出錯", null);
    }

    @ExceptionHandler(UnauthenticatedException.class)
    @ResponseBody
    public Json page401(UnauthenticatedException e) {
        String eMsg = e.getMessage();
        if (StringUtils.startsWithIgnoreCase(eMsg,GUEST_ONLY)){
            return new Json("401", false, Codes.UNAUTHEN, "只容許遊客訪問,若您已登陸,請先退出登陸", null)
                    .data("detail",e.getMessage());
        }else{
            return new Json("401", false, Codes.UNAUTHEN, "用戶未登陸", null)
                    .data("detail",e.getMessage());
        }
    }

    @ExceptionHandler(UnauthorizedException.class)
    @ResponseBody
    public Json page403() {
        return new Json("403", false, Codes.UNAUTHZ, "用戶沒有訪問權限", null);
    }

}

TestController.java

@RestController
@RequestMapping("/t5")
public class Test5Controller {

    // 因爲ShiroConfig中配置了該路徑能夠匿名訪問,因此這接口不須要登陸就能訪問
    @GetMapping("/hello")
    public String hello() {
        return "hello spring boot";
    }

    // 若是ShiroConfig中沒有配置該路徑能夠匿名訪問,因此直接被登陸過濾了。
    // 若是配置了能夠匿名訪問,那這裏在沒有登陸的時候能夠訪問,可是用戶登陸後就不能訪問
    @RequiresGuest
    @GetMapping("/guest")
    public String guest() {
        return "@RequiresGuest";
    }

    @RequiresAuthentication
    @GetMapping("/authn")
    public String authn() {
        return "@RequiresAuthentication";
    }

    @RequiresUser
    @GetMapping("/user")
    public String user() {
        return "@RequiresUser";
    }

    @RequiresPermissions("mvn:install")
    @GetMapping("/mvnInstall")
    public String mvnInstall() {
        return "mvn:install";
    }

    @RequiresPermissions("gradleBuild")
    @GetMapping("/gradleBuild")
    public String gradleBuild() {
        return "gradleBuild";
    }


    @RequiresRoles("js")
    @GetMapping("/js")
    public String js() {
        return "js programmer";
    }


    @RequiresRoles("python")
    @GetMapping("/python")
    public String python() {
        return "python programmer";
    }

}
相關文章
相關標籤/搜索