Spring Boot+Spring Security+Thymeleaf 簡單教程

Smith發表於2019-01-22

因為有一個專案需採用MVC構架,所以學習了Spring Security並記錄下來,希望大家一起學習提供意見

GitHub地址:github.com/Smith-Cruis…

如果有疑問,請在GitHub中釋出issue,我有空會為大家解答的

本專案基於Spring Boot 2 + Spring Security 5 + Thymeleaf 2 + JDK11(你也可以用8,應該區別不大)

實現了以下功能:

  • 基於註解的許可權控制
  • 在Thymeleaf中使用Spring Security的標籤
  • 自定義許可權註解
  • 記住密碼功能

如果需要前後端分離的安全框架搭建教程可以參考:Shiro+JWT+Spring Boot Restful簡易教程

專案演示

如果想要直接體驗,直接clone專案,執行mvn spring-boot:run命令即可進行訪問,網址規則自行看教程後面

首頁

首頁

登入

登入

登出

登出

Home頁面

登出

Admin頁面

登出

403無許可權頁面

登出

Spring Security 基本原理

Spring Security 過濾器鏈

Spring Security實現了一系列的過濾器鏈,就按照下面順序一個一個執行下去。

  1. ....class一些自定義過濾器(在配置的時候你可以自己選擇插到哪個過濾器之前),因為這個需求因人而異,本文不探討,大家可以自己研究
  2. UsernamePasswordAithenticationFilter.classSpring Security 自帶的表單登入驗證過濾器,也是本文主要使用的過濾器
  3. BasicAuthenticationFilter.class
  4. ExceptionTranslation.class 異常直譯器
  5. FilterSecurityInterceptor.class 攔截器最終決定請求能否通過
  6. Controller 我們最後自己編寫的控制器

相關類說明

  • User.class :注意這個類不是我們自己寫的,而是Spring Security官方提供的,他提供了一些基礎的功能,我們可以通過繼承這個類來擴充方法。詳見程式碼中的CustomUser.java
  • UserDetailsService.class: Spring Security官方提供的一個介面,裡面只有一個方法loadUserByUsername() ,Spring Security會呼叫這個方法來獲取資料庫中存在的資料,然後和使用者POST過來的使用者名稱密碼進行比對,從而判斷使用者的使用者名稱密碼是否正確。所以我們需要自己實現loadUserByUsername()這個方法。詳見程式碼中的CustomUserDetailsService.java

專案邏輯

為了體現許可權區別,我們通過HashMap構造了一個資料庫,裡面包含了4個使用者

ID 使用者名稱 密碼 許可權
1 jack jack123 user
2 danny danny123 editor
3 alice alice123 reviewer
4 smith smith123 admin

說明下許可權

user:最基礎的許可權,只要是登入使用者就有 user 許可權

editor:在 user 許可權上面增加了editor的許可權

reviewer:與上同理,editorreviewer 屬於同一級的許可權

admin:包含所有許可權

為了檢驗許可權,我們提供若干個頁面

網址 說明 可訪問許可權
/ 首頁 所有人均可訪問(anonymous)
/login 登入頁面 所有人均可訪問(anonymous)
/logout 退出頁面 所有人均可訪問(anonymous)
/user/home 使用者中心 user
/user/editor editor, admin
/user/reviewer reviewer, admin
/user/admin admin
/403 403錯誤頁面,美化過,大家可以直接用 所有人均可訪問(anonymous)
/404 404錯誤頁面,美化過,大家可以直接用 所有人均可訪問(anonymous)
/500 500錯誤頁面,美化過,大家可以直接用 所有人均可訪問(anonymous)

程式碼配置

Maven 配置

<?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>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>org.inlighting</groupId>
    <artifactId>security-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>security-demo</name>
    <description>Demo project for Spring Boot &amp; Spring Security</description>

    <!--指定JDK版本,大家可以改成自己的-->
    <properties>
        <java.version>11</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--對Thymeleaf新增Spring Security標籤支援-->
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!--開發的熱載入配置-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
複製程式碼

application.properties配置

為了使熱載入(這樣修改模板後無需重啟Tomcat)生效,我們需要在Spring Boot的配置檔案上面加上一段話

spring.thymeleaf.cache=false
複製程式碼

如果需要詳細瞭解熱載入,請看官方文件:docs.spring.io/spring-boot…

Spring Security 配置

首先我們開啟方法註解支援:只需要在類上新增 @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) 註解,我們設定 prePostEnabled = true 是為了支援hasRole()這類表示式。如果想進一步瞭解方法註解可以看 Introduction to Spring Method Security 這篇文章。

SecurityConfig.java

/**
 * 開啟方法註解支援,我們設定prePostEnabled = true是為了後面能夠使用hasRole()這類表示式
 * 進一步瞭解可看教程:https://www.baeldung.com/spring-security-method-security
 */
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * TokenBasedRememberMeServices的生成金鑰,
     * 演算法實現詳見文件:https://docs.spring.io/spring-security/site/docs/5.1.3.RELEASE/reference/htmlsingle/#remember-me-hash-token
     */
    private final String SECRET_KEY = "123456";

    @Autowired
    private CustomUserDetailsService customUserDetailsService;

    /**
     * 必須有此方法,Spring Security官方規定必須要有一個密碼加密方式。
     * 注意:例如這裡用了BCryptPasswordEncoder()的加密方法,那麼在儲存使用者密碼的時候也必須使用這種方法,確保前後一致。
     * 詳情參見專案中Database.java中儲存使用者的邏輯
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 配置Spring Security,下面說明幾點注意事項。
     * 1. Spring Security 預設是開啟了CSRF的,此時我們提交的POST表單必須有隱藏的欄位來傳遞CSRF,
     * 而且在logout中,我們必須通過POST到 /logout 的方法來退出使用者,詳見我們的login.html和logout.html.
     * 2. 開啟了rememberMe()功能後,我們必須提供rememberMeServices,例如下面的getRememberMeServices()方法,
     * 而且我們只能在TokenBasedRememberMeServices中設定cookie名稱、過期時間等相關配置,如果在別的地方同時配置,會報錯。
     * 錯誤示例:xxxx.and().rememberMe().rememberMeServices(getRememberMeServices()).rememberMeCookieName("cookie-name")
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/login") // 自定義使用者登入頁面
                .failureUrl("/login?error") // 自定義登入失敗頁面,前端可以通過url中是否有error來提供友好的使用者登入提示
                .and()
                .logout()
                .logoutUrl("/logout")// 自定義使用者登出頁面
                .logoutSuccessUrl("/")
                .and()
                .rememberMe() // 開啟記住密碼功能
                .rememberMeServices(getRememberMeServices()) // 必須提供
                .key(SECRET_KEY) // 此SECRET需要和生成TokenBasedRememberMeServices的金鑰相同
                .and()
                /*
                 * 預設允許所有路徑所有人都可以訪問,確保靜態資源的正常訪問。
                 * 後面再通過方法註解的方式來控制許可權。
                 */
                .authorizeRequests().anyRequest().permitAll()
                .and()
                .exceptionHandling().accessDeniedPage("/403"); // 許可權不足自動跳轉403
    }

    /**
     * 如果要設定cookie過期時間或其他相關配置,請在下方自行配置
     */
    private TokenBasedRememberMeServices getRememberMeServices() {
        TokenBasedRememberMeServices services = new TokenBasedRememberMeServices(SECRET_KEY, customUserDetailsService);
        services.setCookieName("remember-cookie");
        services.setTokenValiditySeconds(100); // 預設14天
        return services;
    }
}
複製程式碼

UserService.java

自己模擬資料庫操作的Service,用於向自己通過HashMap模擬的資料來源獲取資料。

@Service
public class UserService {

    private Database database = new Database();

    public CustomUser getUserByUsername(String username) {
        CustomUser originUser = database.getDatabase().get(username);
        if (originUser == null) {
            return null;
        }

        /*
         * 此處有坑,之所以這麼做是因為Spring Security獲得到User後,會把User中的password欄位置空,以確保安全。
         * 因為Java類是引用傳遞,為防止Spring Security修改了我們的源頭資料,所以我們複製一個物件提供給Spring Security。
         * 如果通過真實資料庫的方式獲取,則沒有這種問題需要擔心。
          */
        return new CustomUser(originUser.getId(), originUser.getUsername(), originUser.getPassword(), originUser.getAuthorities());
    }
}
複製程式碼

CustomUserDetailsService.java

/**
 * 實現官方提供的UserDetailsService介面即可
 */
@Service
public class CustomUserDetailsService implements UserDetailsService {

    private Logger LOGGER = LoggerFactory.getLogger(getClass());

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        CustomUser user = userService.getUserByUsername(username);
        if (user == null) {
            throw new  UsernameNotFoundException("該使用者不存在");
        }
        LOGGER.info("使用者名稱:"+username+" 角色:"+user.getAuthorities().toString());
        return user;
    }
}
複製程式碼

自定義許可權註解

我們在開發網站的過程中,比如 GET /user/editor這個請求角色為 EDITORADMIN 肯定都可以,如果我們在每一個需要判斷許可權的方法上面寫一長串的許可權表示式,一定很複雜。但是通過自定義許可權註解,我們可以通過 @IsEditor 這樣的方法來判斷,這樣一來就簡單了很多。進一步瞭解可以看:Introduction to Spring Method Security

IsUser.java

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAnyAuthority('ROLE_USER', 'ROLE_EDITOR', 'ROLE_REVIEWER', 'ROLE_ADMIN')")
public @interface IsUser {
}
複製程式碼

IsEditor.java

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAnyRole('ROLE_USER', 'ROLE_EDITOR', 'ROLE_ADMIN')")
public @interface IsEditor {
}
複製程式碼

IsReviewer.java

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAnyRole('ROLE_USER', 'ROLE_REVIEWER', 'ROLE_ADMIN')")
public @interface IsReviewer {
}
複製程式碼

IsAdmin.java

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAnyRole('ROLE_ADMIN')")
public @interface IsAdmin { 
}
複製程式碼

Spring Security自帶表示式

  • hasRole(),是否擁有某一個許可權

  • hasAnyRole(),多個許可權中有一個即可,如 hasAnyRole("ADMIN","USER")

  • hasAuthority()AuthorityRole 很像,唯一的區別就是 Authority 字首多了 ROLE_ ,如 hasAuthority("ROLE_ADMIN") 等價於 hasRole("ADMIN") ,可以參考上面 IsUser.java 的寫法

  • hasAnyAuthority(),同上,多個許可權中有一個即可

  • permitAll(), denyAll(),isAnonymous(), isRememberMe(),通過字面意思可以理解

  • isAuthenticated(), isFullyAuthenticated(),這兩個區別就是isFullyAuthenticated()對認證的安全要求更高。例如使用者通過記住密碼功能登入到系統進行敏感操作,isFullyAuthenticated()會返回false,此時我們可以讓使用者再輸入一次密碼以確保安全,而 isAuthenticated() 只要是登入使用者均返回true

  • principal(), authentication(),例如我們想獲取登入使用者的id,可以通過principal() 返回的 Object 獲取,實際上 principal() 返回的 Object 基本上可以等同我們自己編寫的 CustomUser 。而 authentication() 返回的 AuthenticationPrincipal 的父類,相關操作可看 Authentication 的原始碼。進一步瞭解可以看後面Controller編寫中獲取使用者資料的四種方法

  • hasPermission(),參考字面意思即可

如果想進一步瞭解,可以參考Intro to Spring Security Expressions

新增Thymeleaf支援

我們通過 thymeleaf-extras-springsecurity 來新增Thymeleaf對Spring Security的支援。

Maven配置

上面的Maven配置已經加過了

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
複製程式碼

使用例子

注意我們要在html中新增 xmlns:sec 的支援

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <meta charset="UTF-8">
    <title>Admin</title>
</head>
<body>
<p>This is a home page.</p>
<p>Id: <th:block sec:authentication="principal.id"></th:block></p>
<p>Username: <th:block sec:authentication="principal.username"></th:block></p>
<p>Role: <th:block sec:authentication="principal.authorities"></th:block></p>
</body>
</html>
複製程式碼

如果想進一步瞭解請看文件 thymeleaf-extras-springsecurity

Controller編寫

IndexController.java

本控制器沒有任何的許可權規定

@Controller
public class IndexController {

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

    @GetMapping("/login")
    public String login() {
        return "index/login";
    }

    @GetMapping("/logout")
    public String logout() {
        return "index/logout";
    }
}
複製程式碼

UserController.java

在這個控制器中,我綜合展示了自定義註解的使用和4種獲取使用者資訊的方式

@IsUser // 表明該控制器下所有請求都需要登入後才能訪問
@Controller
@RequestMapping("/user")
public class UserController {

    @GetMapping("/home")
    public String home(Model model) {
        // 方法一:通過SecurityContextHolder獲取
        CustomUser user = (CustomUser)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        model.addAttribute("user", user);
        return "user/home";
    }

    @GetMapping("/editor")
    @IsEditor
    public String editor(Authentication authentication, Model model) {
        // 方法二:通過方法注入的形式獲取Authentication
        CustomUser user = (CustomUser)authentication.getPrincipal();
        model.addAttribute("user", user);
        return "user/editor";
    }

    @GetMapping("/reviewer")
    @IsReviewer
    public String reviewer(Principal principal, Model model) {
        // 方法三:同樣通過方法注入的方法,注意要轉型,此方法很二,不推薦
        CustomUser user = (CustomUser) ((Authentication)principal).getPrincipal();
        model.addAttribute("user", user);
        return "user/reviewer";
    }

    @GetMapping("/admin")
    @IsAdmin
    public String admin() {
        // 方法四:通過Thymeleaf的Security標籤進行,詳情見admin.html
        return "user/admin";
    }
}
複製程式碼

注意

  • 如果有安全控制的方法 A 被同一個類中別的方法呼叫,那麼方法 A 的許可權控制會被忽略,私有方法同樣會受到影響
  • Spring 的 SecurityContext 是執行緒繫結的,如果我們在當前的執行緒中新建了別的執行緒,那麼他們的 SecurityContext 是不共享的,進一步瞭解請看 (Spring Security Context Propagation with @Async)[www.baeldung.com/spring-secu…]

Html的編寫

在編寫html的時候,基本上就是大同小異了,就是注意一點,**如果開啟了CSRF,在編寫表單POST請求的時候新增上隱藏欄位,如 **<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> ,不過大家其實不用加也沒事,因為Thymeleaf自動會加上去的?

總結

教程粗糙,歡迎指正!

如需深入瞭解,如果想系統的學習可以看看 Security with Spring

相關文章