[譯] 用 Apache Shiro 來保護一個 Spring Boot 應用

掘金翻譯計劃發表於2019-03-30

對於 Apache Shiro,我最欣賞的一點是它能夠輕易地處理應用的授權行為。你能夠使用基於角色的訪問控制模型來對使用者進行角色分配,以及對角色進行許可權分配。這使得處理一些不可避免的行為變得簡單。你不需要改動程式碼,只需修改角色許可權。在這篇文章中,我想展示它的易用性,用一個 Spring Boot 程式來介紹我是如何處理以下場景的:

你的老大(最高指揮官)出現在你的桌旁並告訴你,當前的志願者(士兵)註冊應用需要針對不同的員工類別分配不同的許可權。

  • 長官能夠註冊新加入的志願者
  • 下屬(你我這樣的人員)只有閱讀志願者資料的許可權
  • 組織外部的任何人都無法訪問志願者的資料
  • 毋庸置疑的是,老大擁有所有許可權

從 REST 應用來開始

首先,來看看這個 Spring Boot 的例子。它會幫助你從一些進行 CRUD 操作的 REST 接入點來管理一個士兵名單。你將用 Apache Shiro 來新增身份驗證和角色授權。所有程式碼已上傳至 Github

要使用 Apache Shiro, 你所需要做的就是使用 Spring Boot 的 starter,只要在 pom 檔案里加入你所需要的依賴(${shiro.version} 至少需要在 1.4.0 之上):

<dependency>
 <groupId>org.apache.shiro</groupId>
 <artifactId>shiro-spring-boot-web-starter</artifactId>
 <version>${shiro.version}</version>
</dependency>
複製程式碼

接下來看看程式碼,從 StormtrooperController 開始,只需要新增一些註解:

@RestController
@RequestMapping(path = "/troopers", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public class StormtrooperController {

    private final StormtrooperDao trooperDao;

    @Autowired
    public StormtrooperController(StormtrooperDao trooperDao) {
        this.trooperDao = trooperDao;
    }

    @GetMapping()
    @RequiresRoles(logical = Logical.OR, value = {"admin", "officer", "underling"})
    public Collection<Stormtrooper> listTroopers() {
        return trooperDao.listStormtroopers();
    }

    @GetMapping(path = "/{id}")
    @RequiresRoles(logical = Logical.OR, value = {"admin", "officer", "underling"})
    public Stormtrooper getTrooper(@PathVariable("id") String id) throws NotFoundException {
        Stormtrooper stormtrooper = trooperDao.getStormtrooper(id);
        if (stormtrooper == null) {
            throw new NotFoundException(id);
        }
        return stormtrooper;
    }

    @PostMapping()
    @RequiresRoles(logical = Logical.OR, value = {"admin", "officer"})
    public Stormtrooper createTrooper(@RequestBody Stormtrooper trooper) {
        return trooperDao.addStormtrooper(trooper);
    }

    @PostMapping(path = "/{id}")
    @RequiresRoles("admin")
    public Stormtrooper updateTrooper(@PathVariable("id") String id, @RequestBody Stormtrooper updatedTrooper) throws NotFoundException {
        return trooperDao.updateStormtrooper(id, updatedTrooper);
    }

    @DeleteMapping(path = "/{id}")
    @ResponseStatus(value = HttpStatus.NO_CONTENT)
    @RequiresRoles("admin")
    public void deleteTrooper(@PathVariable("id") String id) {
        trooperDao.deleteStormtrooper(id);
    }

}
複製程式碼

在以上的程式碼塊中,使用 Shiro 的 @RequiresRoles 註釋來指定角色。你會看到用邏輯符 OR 來為任何擁有這種角色的人賦予許可權。這很棒,只需要新增一行註解,你的程式碼就已經完成了。

你的程式碼可以到此為止,但是,使用角色的方式並不是那麼靈活,如果直接在程式碼中使用,就會導致程式碼與這些名字的緊密耦合。

不再使用角色

想象一下,你的應用已被部署,並且正常工作了,過了一星期,你的老大來到桌旁,叫你做一些改動:

  • 長官要能夠更新士兵的資料
  • 他覺得“管理員”這個稱呼對於大部分長官來說沒問題,但它不適合大魔王

好,你覺得這個並不難,只需要對方法簽名做一點小改動:

@GetMapping()
@RequiresRoles(logical = Logical.OR, value = {"emperor", "admin", "emperor", "officer", "underling"})
public Collection<Stormtrooper> listTroopers()

@GetMapping(path = "/{id}")
@RequiresRoles(logical = Logical.OR, value = {"emperor", "admin", "officer", "underling"})
public Stormtrooper getTrooper(@PathVariable("id") String id) throws NotFoundException

@PostMapping()
@RequiresRoles(logical = Logical.OR, value = {"emperor", "admin", "officer"})
public Stormtrooper createTrooper(@RequestBody Stormtrooper trooper)

@PostMapping(path = "/{id}")
@RequiresRoles(logical = Logical.OR, value = {"emperor", "admin", "officer"})
public Stormtrooper updateTrooper(@PathVariable("id") String id, @RequestBody Stormtrooper updatedTrooper) throws NotFoundException

@DeleteMapping(path = "/{id}")
@ResponseStatus(value = HttpStatus.NO_CONTENT)
@RequiresRoles(logical = Logical.OR, value = {"emperor", "admin"})
public void deleteTrooper(@PathVariable("id") String id)
複製程式碼

在又一輪的測試與部署之後,你的工作完成了!

等等,往回退一步,在簡單的用例中,角色能夠起到很棒的作用,這種型別的變更也執行良好,然而你知道程式碼還有下次改動。與其每次都因為一些小需求而修改程式碼,還不如將角色從程式碼中分離。替換的方式是改用賦予許可權。你的方法簽名將會變成這樣:

@GetMapping()
@RequiresPermissions("troopers:read")
public Collection<Stormtrooper> listTroopers()

@GetMapping(path = "/{id}")
@RequiresPermissions("troopers:read")
public Stormtrooper getTrooper(@PathVariable("id") String id) throws NotFoundException

@PostMapping()
@RequiresPermissions("troopers:create")
public Stormtrooper createTrooper(@RequestBody Stormtrooper trooper)

@PostMapping(path = "/{id}")
@RequiresPermissions("troopers:update")
public Stormtrooper updateTrooper(@PathVariable("id") String id, @RequestBody Stormtrooper updatedTrooper) throws NotFoundException

@DeleteMapping(path = "/{id}")
@ResponseStatus(value = HttpStatus.NO_CONTENT)
@RequiresPermissions("troopers:delete")
public void deleteTrooper(@PathVariable("id") String id)
複製程式碼

通過使用 Shiro 的 @RequiresPermissions 註解,就能夠在不進行程式碼修改的同時滿足原始需求和新需求。唯一要做的就是將許可權對映到對應的角色,也就是我們的使用者。這件事能夠在外部程式中完成,比如資料庫,或者像本例中一個簡單的配置檔案。

值得注意的是: 在這個例子中,使用者名稱和密碼都是明文儲存的,這對於部落格的文章來說沒什麼問題,但是,嚴格來說,你需要正確地管理你的密碼!

為了實現原來的需求,角色-許可權的對映是這樣的:

role.admin = troopers:*
role.officer = troopers:create,  troopers:read
role.underling = troopers:read
複製程式碼

對於後續的需求,只需要在檔案中加入 『emperor』 角色,以及給長官們新增 “update” 許可權:

role.emperor = *
role.admin = troopers:*
role.officer = troopers:create,troopers:read,troopers:update
role.underling = troopers:read
複製程式碼

如果你覺得這授權語句的語法看上去有點奇怪,可以從 Apache Shiro 的萬用字元授權 文件中來獲得一些深入的瞭解。

Apache Shiro 和 Spring

我們已經介紹了 Maven 依賴和 REST 控制器,但我們的應用還需要一個 Realm 和異常處理機制。

如果你看過 SpringBootApp 類,你就會注意到有一些不在樣例中的東西。

@Bean
public Realm realm() {

 // uses 'classpath:shiro-users.properties' by default
 PropertiesRealm realm = new PropertiesRealm();

 // Caching isn't needed in this example, but we can still turn it on
 realm.setCachingEnabled(true);

 return realm;

}

@Bean

public ShiroFilterChainDefinition shiroFilterChainDefinition() {

 DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();

 // use permissive to NOT require authentication, our controller Annotations will decide that

 chainDefinition.addPathDefinition("/**", "authcBasic[permissive]");

 return chainDefinition;

}

@Bean
public CacheManager cacheManager() {

 // Caching isn't needed in this example, but we will use the MemoryConstrainedCacheManager for this example.

 return new MemoryConstrainedCacheManager();

}
複製程式碼

首先,你先定義一個 Shiro 的 Realm。realm 只是一個特定的儲存使用者的 DAO,Shiro 支援多種不同型別的 Realm (活動目錄、LDAP、資料庫和檔案等等)。

接下來看看 ShiroFilterChainDefinition,你配置了允許基本的身份驗證功能,但是並不是通過『permissive』選項來獲取這個功能。這樣你的註釋就可以配置所有內容了。你可以使用 Ant 樣式的路徑來定義 URL 對映許可權,而不是使用註解(或者使用一些其他的)。這個例子看起來是這樣子的:

chainDefinition.addPathDefinition("/troopers/**", "authcBasic, rest[troopers]");
複製程式碼

這樣做將所有以 /troopers 開頭的資源對映到要求基本身份驗證,並且使用 ‘rest’ 過濾器,它基於 HTTP 請求方法,且在許可權字串後附加了一個 CRUD 操作。舉個例子,一個 HTTPGET 方法會對映到 ‘read’,所以對於一個 GET 請求的完整許可權字串為troopers:read(就像你用註解做的那樣)。

異常處理

程式碼中的最後一部分就是異常處理了

@ExceptionHandler(UnauthenticatedException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public void handleException(UnauthenticatedException e) {

 log.debug("{} was thrown", e.getClass(), e);

}

@ExceptionHandler(AuthorizationException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public void handleException(AuthorizationException e) {

 log.debug("{} was thrown", e.getClass(), e);

}

@ExceptionHandler(NotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public @ResponseBody ErrorMessage handleException(NotFoundException e) {

 String id = e.getMessage();

 return new ErrorMessage("Trooper Not Found: "+ id +", why aren't you at your post? "+ id +", do you copy?");

}
複製程式碼

前兩個處理 Shiro 異常的例子,只是簡單的將狀態碼改至 401 或 403。401 針對的是使用者名稱/密碼的無效或缺失,403 是因為已登入的使用者無權訪問受限資源。最後,你將要用 404 來處理 NotFoundException,並且返回一個 JSON 序列化的 ErrorMessage 物件。

火力全開!

如果你把這些組合起來,或者你直接從 GitHub上把程式碼搬過來,你就能用 mvn spring-boot:run 來啟動應用。一旦執行起來,你就能夠開始傳送請求了!

$ curl http://localhost:8080/troopers
HTTP/1.1 401
Content-Length: 0
Date: Thu, 26 Jan 2017 21:12:41 GMT
WWW-Authenticate: BASIC realm="application"
複製程式碼

別忘了,你需要驗證你的身份!

$ curl --user emperor:secret http://localhost:8080/troopers
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Date: Thu, 26 Jan 2017 21:14:17 GMT
Transfer-Encoding: chunked

[
 {
 "id": "FN-0128",
 "planetOfOrigin": "Naboo",
 "species": "Twi'lek",
 "type": "Sand"
 },
 {
"id": "FN-1383",
"planetOfOrigin": "Hoth",
"species": "Human",
"type": "Basic"
},
{
"id": "FN-1692",
"planetOfOrigin": "Hoth",
"species": "Nikto",
"type": "Marine"
},

...
複製程式碼

一個 404 是這樣的:

$ curl --user emperor:secret http://localhost:8080/troopers/TK-421
HTTP/1.1 404
Content-Type: application/json;charset=UTF-8
Date: Thu, 26 Jan 2017 21:15:54 GMT
Transfer-Encoding: chunked

{
 "error": "Trooper Not Found: TK-421, why aren't you at your post? TK-421, do you copy?"
}
複製程式碼

瞭解更多有關 Apache Shiro 的資訊

這個例子演示瞭如何輕鬆將 Apache Shiro 整合至 Spring Boot 應用,以及如何使用許可權來增大角色的靈活性,所有的這些只需要在控制器中加一條註解。

我們很高興能夠為 Apache Shiro 做出貢獻,並且將這一貢獻轉發至 Okta 了。期待我們團隊能夠推出更多 Shiro 的內容,包括給 Okta 和 OAuth 的 Shiro 使用手冊以及如何在此志願者應用程式中新增 AngularJS 前端程式碼。請繼續關注,帝國需要你!

關於這個例子,如果你有任何疑問,請將它們傳送至 Apache Shiro 的使用者列表或者是我的 Twitter 賬戶,也可以直接在下方評論區留言!

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章