?Spring Boot + Spring Security + Thymeleaf 實戰(zhàn)教程!

Spring Security 基本原理
Spring Security 過濾器鏈
Spring Security實現(xiàn)了一系列的過濾器鏈,就按照下面順序一個一個執(zhí)行下去。
....class一些自定義過濾器(在配置的時候你可以自己選擇插到哪個過濾器之前),因為這個需求因人而異,本文不探討,大家可以自己研究UsernamePasswordAithenticationFilter.classSpring Security 自帶的表單登入驗證過濾器,也是本文主要使用的過濾器BasicAuthenticationFilter.classExceptionTranslation.class異常解釋器FilterSecurityInterceptor.class攔截器最終決定請求能否通過Controller我們最后自己編寫的控制器
相關(guān)類說明
User.class:注意這個類不是我們自己寫的,而是Spring Security官方提供的,他提供了一些基礎(chǔ)的功能,我們可以通過繼承這個類來擴充方法。詳見代碼中的CustomUser.javaUserDetailsService.class:Spring Security官方提供的一個接口,里面只有一個方法loadUserByUsername(),Spring Security會調(diào)用這個方法來獲取數(shù)據(jù)庫中存在的數(shù)據(jù),然后和用戶POST過來的用戶名密碼進行比對,從而判斷用戶的用戶名密碼是否正確。所以我們需要自己實現(xiàn)loadUserByUsername()這個方法。詳見代碼中的CustomUserDetailsService.java。
項目邏輯
為了體現(xiàn)權(quán)限區(qū)別,我們通過HashMap構(gòu)造了一個數(shù)據(jù)庫,里面包含了4個用戶
| ID | 用戶名 | 密碼 | 權(quán)限 |
|---|---|---|---|
| 1 | jack | jack123 | user |
| 2 | danny | danny123 | editor |
| 3 | alice | alice123 | reviewer |
| 4 | smith | smith123 | admin |
說明下權(quán)限
user:最基礎(chǔ)的權(quán)限,只要是登入用戶就有 user 權(quán)限
editor:在 user 權(quán)限上面增加了 editor 的權(quán)限
reviewer:與上同理,editor 和 reviewer 屬于同一級的權(quán)限
admin:包含所有權(quán)限
為了檢驗權(quán)限,我們提供若干個頁面
| 網(wǎng)址 | 說明 | 可訪問權(quán)限 |
|---|---|---|
| / | 首頁 | 所有人均可訪問(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 & 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>
<!--開發(fā)的熱加載配置-->
<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
如果需要詳細了解熱加載,請看官方文檔:https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto-hotswapping
Spring Security 配置
首先我們開啟方法注解支持:只需要在類上添加 @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) 注解,我們設(shè)置 prePostEnabled = true 是為了支持 hasRole() 這類表達式。如果想進一步了解方法注解可以看 Introduction to Spring Method Security 這篇文章。
SecurityConfig.java
/**
* 開啟方法注解支持,我們設(shè)置prePostEnabled = true是為了后面能夠使用hasRole()這類表達式
* 進一步了解可看教程:https://www.baeldung.com/spring-security-method-security
*/
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* TokenBasedRememberMeServices的生成密鑰,
* 算法實現(xiàn)詳見文檔: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官方規(guī)定必須要有一個密碼加密方式。
* 注意:例如這里用了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中設(shè)置cookie名稱、過期時間等相關(guān)配置,如果在別的地方同時配置,會報錯。
* 錯誤示例: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()
/*
* 默認允許所有路徑所有人都可以訪問,確保靜態(tài)資源的正常訪問。
* 后面再通過方法注解的方式來控制權(quán)限。
*/
.authorizeRequests().anyRequest().permitAll()
.and()
.exceptionHandling().accessDeniedPage("/403"); // 權(quán)限不足自動跳轉(zhuǎn)403
}
/**
* 如果要設(shè)置cookie過期時間或其他相關(guān)配置,請在下方自行配置
*/
private TokenBasedRememberMeServices getRememberMeServices() {
TokenBasedRememberMeServices services = new TokenBasedRememberMeServices(SECRET_KEY, customUserDetailsService);
services.setCookieName("remember-cookie");
services.setTokenValiditySeconds(100); // 默認14天
return services;
}
}
UserService.java
自己模擬數(shù)據(jù)庫操作的Service,用于向自己通過HashMap模擬的數(shù)據(jù)源獲取數(shù)據(jù)。
@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修改了我們的源頭數(shù)據(jù),所以我們復制一個對象提供給Spring Security。
* 如果通過真實數(shù)據(jù)庫的方式獲取,則沒有這種問題需要擔心。
*/
return new CustomUser(originUser.getId(), originUser.getUsername(), originUser.getPassword(), originUser.getAuthorities());
}
}
CustomUserDetailsService.java
/**
* 實現(xiàn)官方提供的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;
}
}
自定義權(quán)限注解
我們在開發(fā)網(wǎng)站的過程中,比如 GET /user/editor這個請求角色為 EDITOR 和 ADMIN 肯定都可以,如果我們在每一個需要判斷權(quán)限的方法上面寫一長串的權(quán)限表達式,一定很復雜。但是通過自定義權(quán)限注解,我們可以通過 @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(),是否擁有某一個權(quán)限hasAnyRole(),多個權(quán)限中有一個即可,如hasAnyRole("ADMIN","USER")hasAuthority(),Authority和Role很像,唯一的區(qū)別就是Authority前綴多了ROLE_,如hasAuthority("ROLE_ADMIN")等價于hasRole("ADMIN"),可以參考上面IsUser.java的寫法hasAnyAuthority(),同上,多個權(quán)限中有一個即可permitAll(),denyAll(),isAnonymous(),isRememberMe(),通過字面意思可以理解isAuthenticated(),isFullyAuthenticated(),這兩個區(qū)別就是isFullyAuthenticated()對認證的安全要求更高。例如用戶通過記住密碼功能登入到系統(tǒng)進行敏感操作,isFullyAuthenticated()會返回false,此時我們可以讓用戶再輸入一次密碼以確保安全,而isAuthenticated()只要是登入用戶均返回true。principal(),authentication(),例如我們想獲取登入用戶的id,可以通過principal()返回的Object獲取,實際上principal()返回的Object基本上可以等同我們自己編寫的CustomUser。而authentication()返回的Authentication是Principal的父類,相關(guān)操作可看Authentication的源碼。進一步了解可以看后面Controller編寫中獲取用戶數(shù)據(jù)的四種方法hasPermission(),參考字面意思即可
如果想進一步了解,可以參考 Intro to Spring Security Expressions。
添加Thymeleaf支持
我們通過 thymeleaf-extras-springsecurity 來添加Thymeleaf對Spring Security的支持。
Maven配置
上面的Maven配置已經(jīng)加過了
<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
本控制器沒有任何的權(quán)限規(guī)定
@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) {
// 方法三:同樣通過方法注入的方法,注意要轉(zhuǎn)型,此方法很二,不推薦
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 被同一個類中別的方法調(diào)用,那么方法 A 的權(quán)限控制會被忽略,私有方法同樣會受到影響 Spring 的 SecurityContext是線程綁定的,如果我們在當前的線程中新建了別的線程,那么他們的SecurityContext是不共享的,進一步了解請看 Spring Security Context Propagation with @Async
Html的編寫
在編寫html的時候,基本上就是大同小異了,就是注意一點,**如果開啟了CSRF,在編寫表單POST請求的時候添加上隱藏字段,如 **<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> ,不過大家其實不用加也沒事,因為Thymeleaf自動會加上去的??。
github地址:https://github.com/Smith-Cruise/Spring-Boot-Security-Thymeleaf-Demo
Docker + FastDFS + Spring Boot 一鍵式搭建分布式文件服務(wù)器

