1. 概述
安全性是應用程序開發領域,尤其是在企業級 Web 和移動應用程序領域中的首要關注點。
在本快速教程中,我們將比較兩個流行的 Java 安全框架——Apache Shiro 和 Spring Security。
2. 背景介紹
Apache Shiro 起源於2004年,當時名為JSecurity,並在2008年被Apache基金會接受。截至本文撰寫時,它已經發布了多個版本,最新版本為1.5.3。
Spring Security始於2003年的Acegi,並在2008年與Spring框架一起發佈了其首次公共版本。自成立以來,它經歷了多個迭代版本,截至本文撰寫時,當前GA版本為5.3.2。
這兩種技術都提供 身份驗證和授權支持,以及密碼學和會話管理解決方案。 此外,Spring Security還提供對諸如CSRF和會話固定等攻擊的“第一級”保護。
在接下來的幾個部分中,我們將看到這兩種技術如何處理身份驗證和授權的示例。為了保持簡單,我們將使用基於Spring Boot的MVC應用程序以及FreeMarker模板。
3. 配置 Apache Shiro
為了開始,讓我們看看兩種框架之間的配置差異。
3.1. Maven 依賴
由於我們將 Shiro 用在 Spring Boot 應用中,因此需要它的 starter 和 shiro-core 模塊:
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<version>1.5.3</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.5.3</version>
</dependency>最新版本可以在 Maven Central 找到。
3.2. 創建 Realm
為了在內存中聲明用户及其角色和權限,我們需要創建一個擴展 Shiro 的 <em>JdbcRealm</em> 的 Realm。我們將定義兩個用户——Tom 和 Jerry,分別擁有 USER 和 ADMIN 角色:
public class CustomRealm extends JdbcRealm {
private Map<String, String> credentials = new HashMap<>();
private Map<String, Set> roles = new HashMap<>();
private Map<String, Set> permissions = new HashMap<>();
{
credentials.put("Tom", "password");
credentials.put("Jerry", "password");
roles.put("Jerry", new HashSet<>(Arrays.asList("ADMIN")));
roles.put("Tom", new HashSet<>(Arrays.asList("USER")));
permissions.put("ADMIN", new HashSet<>(Arrays.asList("READ", "WRITE")));
permissions.put("USER", new HashSet<>(Arrays.asList("READ")));
}
}接下來,為了啓用對該身份驗證和授權的檢索,我們需要覆蓋幾個方法:
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
throws AuthenticationException {
UsernamePasswordToken userToken = (UsernamePasswordToken) token;
if (userToken.getUsername() == null || userToken.getUsername().isEmpty() ||
!credentials.containsKey(userToken.getUsername())) {
throw new UnknownAccountException("User doesn't exist");
}
return new SimpleAuthenticationInfo(userToken.getUsername(),
credentials.get(userToken.getUsername()), getName());
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
Set roles = new HashSet<>();
Set permissions = new HashSet<>();
for (Object user : principals) {
try {
roles.addAll(getRoleNamesForUser(null, (String) user));
permissions.addAll(getPermissions(null, null, roles));
} catch (SQLException e) {
logger.error(e.getMessage());
}
}
SimpleAuthorizationInfo authInfo = new SimpleAuthorizationInfo(roles);
authInfo.setStringPermissions(permissions);
return authInfo;
}
方法 doGetAuthorizationInfo 使用了幾個輔助方法來獲取用户的角色和權限:
@Override
protected Set getRoleNamesForUser(Connection conn, String username)
throws SQLException {
if (!roles.containsKey(username)) {
throw new SQLException("User doesn't exist");
}
return roles.get(username);
}
@Override
protected Set getPermissions(Connection conn, String username, Collection roles)
throws SQLException {
Set userPermissions = new HashSet<>();
for (String role : roles) {
if (!permissions.containsKey(role)) {
throw new SQLException("Role doesn't exist");
}
userPermissions.addAll(permissions.get(role));
}
return userPermissions;
}
接下來,我們需要將該 CustomRealm 作為 Bean 包含到我們的 Boot Application 中:
@Bean
public Realm customRealm() {
return new CustomRealm();
}此外,為了配置我們端點的身份驗證,我們還需要另一個 Bean:
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition filter = new DefaultShiroFilterChainDefinition();
filter.addPathDefinition("/home", "authc");
filter.addPathDefinition("/**", "anon");
return filter;
}在這裏,我們使用了一個 DefaultShiroFilterChainDefinition 實例,指定我們的 /home 端點只能被認證用户訪問。
對於配置,這已經足夠了,Shiro 會自動處理其餘部分。
4. 配置 Spring 安全
現在讓我們看看如何在 Spring 中實現相同的功能。
4.1. Maven 依賴
首先,依賴項:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>最新版本可以在 Maven Central 找到。
4.2. 配置類
接下來,我們將 Spring Security 的配置定義在一個名為 SecurityConfig 的類中:
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf()
.disable()
.authorizeRequests(authorize -> authorize.antMatchers("/index", "/login")
.permitAll()
.antMatchers("/home", "/logout")
.authenticated()
.antMatchers("/admin/**")
.hasRole("ADMIN"))
.formLogin(formLogin -> formLogin.loginPage("/login")
.failureUrl("/login-error"));
return http.build();
}
@Bean
public InMemoryUserDetailsManager userDetailsService() throws Exception {
UserDetails jerry = User.withUsername("Jerry")
.password(passwordEncoder().encode("password"))
.authorities("READ", "WRITE")
.roles("ADMIN")
.build();
UserDetails tom = User.withUsername("Tom")
.password(passwordEncoder().encode("password"))
.authorities("READ")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(jerry, tom);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
如我們所見,我們構建了一個 UserDetails 對象來聲明我們的用户及其角色和權限。此外,我們使用 BCryptPasswordEncoder 對密碼進行了編碼。
Spring Security 還為我們提供了 HttpSecurity 對象,用於進一步配置。對於我們的示例,我們允許:
- 任何人訪問我們的
index和login頁面 - 只有經過身份驗證的用户才能進入
home頁面和logout頁面 - 只有具有 ADMIN 角色用户才能訪問
admin頁面
我們還定義了對基於表單身份驗證的支持,將用户重定向到 login 端點。如果身份驗證失敗,我們的用户將被重定向到 /login-error。
5. 控制器和端點
現在讓我們來查看這兩個應用程序的 Web 控制器映射。 儘管它們將使用相同的端點,但某些實現可能會有所不同。
5.1. 視圖渲染端點
對於渲染視圖的端點,實現方式相同:
@GetMapping("/")
public String index() {
return "index";
}
@GetMapping("/login")
public String showLoginPage() {
return "login";
}
@GetMapping("/home")
public String getMeHome(Model model) {
addUserAttributes(model);
return "home";
}我們雙方的控制器實現,Shiro 以及 Spring Security,都會在根端點返回 index.ftl,在登錄端點返回 login.ftl,在主頁端點返回 home.ftl。
然而,在主頁端點(/home)addUserAttributes方法的定義將在這兩個控制器之間有所不同。該方法會檢查當前已登錄用户的屬性。
Shiro 提供了一個 SecurityUtils#getSubject 用於檢索當前的 Subject 以及其角色和權限:
private void addUserAttributes(Model model) {
Subject currentUser = SecurityUtils.getSubject();
String permission = "";
if (currentUser.hasRole("ADMIN")) {
model.addAttribute("role", "ADMIN");
} else if (currentUser.hasRole("USER")) {
model.addAttribute("role", "USER");
}
if (currentUser.isPermitted("READ")) {
permission = permission + " READ";
}
if (currentUser.isPermitted("WRITE")) {
permission = permission + " WRITE";
}
model.addAttribute("username", currentUser.getPrincipal());
model.addAttribute("permission", permission);
}另一方面,Spring Security 從其 SecurityContextHolder 的上下文中提供一個 Authentication 對象,用於此目的:
private void addUserAttributes(Model model) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && !auth.getClass().equals(AnonymousAuthenticationToken.class)) {
User user = (User) auth.getPrincipal();
model.addAttribute("username", user.getUsername());
Collection<GrantedAuthority> authorities = user.getAuthorities();
for (GrantedAuthority authority : authorities) {
if (authority.getAuthority().contains("USER")) {
model.addAttribute("role", "USER");
model.addAttribute("permissions", "READ");
} else if (authority.getAuthority().contains("ADMIN")) {
model.addAttribute("role", "ADMIN");
model.addAttribute("permissions", "READ WRITE");
}
}
}
}5.2. POST 登錄端點
在 Shiro 中,我們將用户輸入的憑據映射到一個 POJO:
public class UserCredentials {
private String username;
private String password;
// getters and setters
}然後,我們將創建一個 UsernamePasswordToken 以登錄用户,或使用 Subject :
@PostMapping("/login")
public String doLogin(HttpServletRequest req, UserCredentials credentials, RedirectAttributes attr) {
Subject subject = SecurityUtils.getSubject();
if (!subject.isAuthenticated()) {
UsernamePasswordToken token = new UsernamePasswordToken(credentials.getUsername(),
credentials.getPassword());
try {
subject.login(token);
} catch (AuthenticationException ae) {
logger.error(ae.getMessage());
attr.addFlashAttribute("error", "Invalid Credentials");
return "redirect:/login";
}
}
return "redirect:/home";
}在 Spring Security 方面,這只是簡單的重定向到主頁的問題。Spring 的身份驗證流程,由其 <em >UsernamePasswordAuthenticationFilter</em> 處理,對我們來説是透明的。
@PostMapping("/login")
public String doLogin(HttpServletRequest req) {
return "redirect:/home";
}5.3. 僅限管理員接口
現在,我們來看一個需要進行基於角色的訪問的場景。假設我們有一個 /admin 接口,只有 ADMIN 角色才能訪問。
讓我們看看如何在 Shiro 中實現這一點:
@GetMapping("/admin")
public String adminOnly(ModelMap modelMap) {
addUserAttributes(modelMap);
Subject currentUser = SecurityUtils.getSubject();
if (currentUser.hasRole("ADMIN")) {
modelMap.addAttribute("adminContent", "only admin can view this");
}
return "home";
}我們提取了當前已登錄的用户,檢查其是否具有 ADMIN 角色,並據此添加了相應內容。
在 Spring Security 中,無需通過編程方式檢查用户角色,我們已經在 SecurityConfig 中定義了哪些用户可以訪問該端點。因此,現在只需要添加業務邏輯即可:
@GetMapping("/admin")
public String adminOnly(HttpServletRequest req, Model model) {
addUserAttributes(model);
model.addAttribute("adminContent", "only admin can view this");
return "home";
}5.4. 註銷端點
最後,讓我們實現註銷端點。
在 Shiro 中,我們只需調用 Subject#logout:
@PostMapping("/logout")
public String logout() {
Subject subject = SecurityUtils.getSubject();
subject.logout();
return "redirect:/";
}對於 Spring,我們未定義任何註銷映射。在這種情況下,其默認註銷機制會啓動,因為我們已經在配置中創建了一個 SecurityFilterChain Bean。
6. Apache Shiro 與 Spring Security
現在我們已經瞭解了實現差異,接下來讓我們探討一些其他方面。
在社區支持方面,Spring Framework 總體擁有龐大的開發者社區,積極參與其開發和使用。由於 Spring Security 是 Spring Framework 的一部分,因此它也必須享受同樣的優勢。儘管 Shiro 比較流行,但其社區支持遠不及 Spring Security 強大。
在文檔方面,Spring 再次勝出。
然而,Spring Security 存在一定的學習曲線。 相比之下,Shiro 則易於理解。 對於桌面應用程序,通過 shiro.ini 進行配置更加簡單。
但正如我們在示例代碼片段中所看到的,Spring Security 能夠很好地將業務邏輯和安全功能分離,真正將安全作為一種橫向關注點。
7. 結論
在本教程中,我們對比了 Apache Shiro 與 Spring Security。
我們只是對這些框架提供了初步的瞭解,還有很多地方值得進一步探索。 還有許多其他的替代方案,例如 JAAS 和 OACC。 儘管如此,憑藉其優勢,Spring Security 在目前看來似乎佔據了主導地位。