Spring Boot + Spring Security + Thymeleaf 簡(jiǎn)單教程
因?yàn)橛幸粋€(gè)項(xiàng)目需采用MVC構(gòu)架,所以學(xué)習(xí)了Spring Security并記錄下來(lái),希望大家一起學(xué)習(xí)提供意見
GitHub地址:https://github.com/Smith-Cruise/Spring-Boot-Security-Thymeleaf-Demo。
原文地址:https://www.inlighting.org/archives/spring-boot-security-thymeleaf。
如果有疑問,請(qǐng)?jiān)?GitHub 中發(fā)布 issue,我有空會(huì)為大家解答的
本項(xiàng)目基于Spring Boot 2 + Spring Security 5 + Thymeleaf 2 + JDK11(你也可以用8,應(yīng)該區(qū)別不大)
實(shí)現(xiàn)了以下功能:
- 基于注解的權(quán)限控制
- 在Thymeleaf中使用Spring Security的標(biāo)簽
- 自定義權(quán)限注解
- 記住密碼功能
如果需要前后端分離的安全框架搭建教程可以參考:Spring Boot 2 + Spring Security 5 + JWT 的單頁(yè)應(yīng)用Restful解決方案
項(xiàng)目演示
如果想要直接體驗(yàn),直接 clone 項(xiàng)目,運(yùn)行 mvn spring-boot:run 命令即可進(jìn)行訪問,網(wǎng)址規(guī)則自行看教程后面
首頁(yè)

登入

登出

Home頁(yè)面

Admin頁(yè)面

403無(wú)權(quán)限頁(yè)面

Spring Security 基本原理
Spring Security 過(guò)濾器鏈
Spring Security實(shí)現(xiàn)了一系列的過(guò)濾器鏈,就按照下面順序一個(gè)一個(gè)執(zhí)行下去。
-
....class一些自定義過(guò)濾器(在配置的時(shí)候你可以自己選擇插到哪個(gè)過(guò)濾器之前),因?yàn)檫@個(gè)需求因人而異,本文不探討,大家可以自己研究 -
UsernamePasswordAithenticationFilter.classSpring Security 自帶的表單登入驗(yàn)證過(guò)濾器,也是本文主要使用的過(guò)濾器 BasicAuthenticationFilter.class-
ExceptionTranslation.class異常解釋器 -
FilterSecurityInterceptor.class攔截器最終決定請(qǐng)求能否通過(guò) -
Controller我們最后自己編寫的控制器
相關(guān)類說(shuō)明
-
User.class:注意這個(gè)類不是我們自己寫的,而是Spring Security官方提供的,他提供了一些基礎(chǔ)的功能,我們可以通過(guò)繼承這個(gè)類來(lái)擴(kuò)充方法。詳見代碼中的CustomUser.java -
UserDetailsService.class: Spring Security官方提供的一個(gè)接口,里面只有一個(gè)方法loadUserByUsername(),Spring Security會(huì)調(diào)用這個(gè)方法來(lái)獲取數(shù)據(jù)庫(kù)中存在的數(shù)據(jù),然后和用戶POST過(guò)來(lái)的用戶名密碼進(jìn)行比對(duì),從而判斷用戶的用戶名密碼是否正確。所以我們需要自己實(shí)現(xiàn)loadUserByUsername()這個(gè)方法。詳見代碼中的CustomUserDetailsService.java。
項(xiàng)目邏輯
為了體現(xiàn)權(quán)限區(qū)別,我們通過(guò)HashMap構(gòu)造了一個(gè)數(shù)據(jù)庫(kù),里面包含了4個(gè)用戶
| ID | 用戶名 | 密碼 | 權(quán)限 |
|---|---|---|---|
| 1 | jack | jack123 | user |
| 2 | danny | danny123 | editor |
| 3 | alice | alice123 | reviewer |
| 4 | smith | smith123 | admin |
說(shuō)明下權(quán)限
user:最基礎(chǔ)的權(quán)限,只要是登入用戶就有 user 權(quán)限
editor:在 user 權(quán)限上面增加了 editor 的權(quán)限
reviewer:與上同理,editor 和 reviewer 屬于同一級(jí)的權(quán)限
admin:包含所有權(quán)限
為了檢驗(yàn)權(quán)限,我們提供若干個(gè)頁(yè)面
| 網(wǎng)址 | 說(shuō)明 | 可訪問權(quán)限 |
|---|---|---|
| / | 首頁(yè) | 所有人均可訪問(anonymous) |
| /login | 登入頁(yè)面 | 所有人均可訪問(anonymous) |
| /logout | 退出頁(yè)面 | 所有人均可訪問(anonymous) |
| /user/home | 用戶中心 | user |
| /user/editor | editor, admin | |
| /user/reviewer | reviewer, admin | |
| /user/admin | admin | |
| /403 | 403錯(cuò)誤頁(yè)面,美化過(guò),大家可以直接用 | 所有人均可訪問(anonymous) |
| /404 | 404錯(cuò)誤頁(yè)面,美化過(guò),大家可以直接用 | 所有人均可訪問(anonymous) |
| /500 | 500錯(cuò)誤頁(yè)面,美化過(guò),大家可以直接用 | 所有人均可訪問(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>
<!--對(duì)Thymeleaf添加Spring Security標(biāo)簽支持-->
<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配置
為了使熱加載(這樣修改模板后無(wú)需重啟 Tomcat )生效,我們需要在Spring Boot的配置文件上面加上一段話
spring.thymeleaf.cache=false
如果需要詳細(xì)了解熱加載,請(qǐng)看官方文檔:https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto-hotswapping
Spring Security 配置
首先我們開啟方法注解支持:只需要在類上添加 @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) 注解,我們?cè)O(shè)置 prePostEnabled = true 是為了支持 hasRole() 這類表達(dá)式。如果想進(jìn)一步了解方法注解可以看 Introduction to Spring Method Security 這篇文章。
SecurityConfig.java
/**
* 開啟方法注解支持,我們?cè)O(shè)置prePostEnabled = true是為了后面能夠使用hasRole()這類表達(dá)式
* 進(jìn)一步了解可看教程:https://www.baeldung.com/spring-security-method-security
*/
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* TokenBasedRememberMeServices的生成密鑰,
* 算法實(shí)現(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ī)定必須要有一個(gè)密碼加密方式。
* 注意:例如這里用了BCryptPasswordEncoder()的加密方法,那么在保存用戶密碼的時(shí)候也必須使用這種方法,確保前后一致。
* 詳情參見項(xiàng)目中Database.java中保存用戶的邏輯
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 配置Spring Security,下面說(shuō)明幾點(diǎn)注意事項(xiàng)。
* 1. Spring Security 默認(rèn)是開啟了CSRF的,此時(shí)我們提交的POST表單必須有隱藏的字段來(lái)傳遞CSRF,
* 而且在logout中,我們必須通過(guò)POST到 /logout 的方法來(lái)退出用戶,詳見我們的login.html和logout.html.
* 2. 開啟了rememberMe()功能后,我們必須提供rememberMeServices,例如下面的getRememberMeServices()方法,
* 而且我們只能在TokenBasedRememberMeServices中設(shè)置cookie名稱、過(guò)期時(shí)間等相關(guān)配置,如果在別的地方同時(shí)配置,會(huì)報(bào)錯(cuò)。
* 錯(cuò)誤示例:xxxx.and().rememberMe().rememberMeServices(getRememberMeServices()).rememberMeCookieName("cookie-name")
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login") // 自定義用戶登入頁(yè)面
.failureUrl("/login?error") // 自定義登入失敗頁(yè)面,前端可以通過(guò)url中是否有error來(lái)提供友好的用戶登入提示
.and()
.logout()
.logoutUrl("/logout")// 自定義用戶登出頁(yè)面
.logoutSuccessUrl("/")
.and()
.rememberMe() // 開啟記住密碼功能
.rememberMeServices(getRememberMeServices()) // 必須提供
.key(SECRET_KEY) // 此SECRET需要和生成TokenBasedRememberMeServices的密鑰相同
.and()
/*
* 默認(rèn)允許所有路徑所有人都可以訪問,確保靜態(tài)資源的正常訪問。
* 后面再通過(guò)方法注解的方式來(lái)控制權(quán)限。
*/
.authorizeRequests().anyRequest().permitAll()
.and()
.exceptionHandling().accessDeniedPage("/403"); // 權(quán)限不足自動(dòng)跳轉(zhuǎn)403
}
/**
* 如果要設(shè)置cookie過(guò)期時(shí)間或其他相關(guān)配置,請(qǐng)?jiān)谙路阶孕信渲? */
private TokenBasedRememberMeServices getRememberMeServices() {
TokenBasedRememberMeServices services = new TokenBasedRememberMeServices(SECRET_KEY, customUserDetailsService);
services.setCookieName("remember-cookie");
services.setTokenValiditySeconds(100); // 默認(rèn)14天
return services;
}
}
UserService.java
自己模擬數(shù)據(jù)庫(kù)操作的Service,用于向自己通過(guò)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;
}
/*
* 此處有坑,之所以這么做是因?yàn)镾pring Security獲得到User后,會(huì)把User中的password字段置空,以確保安全。
* 因?yàn)镴ava類是引用傳遞,為防止Spring Security修改了我們的源頭數(shù)據(jù),所以我們復(fù)制一個(gè)對(duì)象提供給Spring Security。
* 如果通過(guò)真實(shí)數(shù)據(jù)庫(kù)的方式獲取,則沒有這種問題需要擔(dān)心。
*/
return new CustomUser(originUser.getId(), originUser.getUsername(), originUser.getPassword(), originUser.getAuthorities());
}
}
CustomUserDetailsService.java
/**
* 實(shí)現(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)限注解
我們?cè)陂_發(fā)網(wǎng)站的過(guò)程中,比如 GET /user/editor 這個(gè)請(qǐng)求角色為 EDITOR 和 ADMIN 肯定都可以,如果我們?cè)诿恳粋€(gè)需要判斷權(quán)限的方法上面寫一長(zhǎng)串的權(quán)限表達(dá)式,一定很復(fù)雜。但是通過(guò)自定義權(quán)限注解,我們可以通過(guò) @IsEditor 這樣的方法來(lái)判斷,這樣一來(lái)就簡(jiǎn)單了很多。進(jìn)一步了解可以看: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自帶表達(dá)式
hasRole(),是否擁有某一個(gè)權(quán)限hasAnyRole(),多個(gè)權(quán)限中有一個(gè)即可,如hasAnyRole("ADMIN","USER")hasAuthority(),Authority和Role很像,唯一的區(qū)別就是Authority前綴多了ROLE_,如hasAuthority("ROLE_ADMIN")等價(jià)于hasRole("ADMIN"),可以參考上面IsUser.java的寫法hasAnyAuthority(),同上,多個(gè)權(quán)限中有一個(gè)即可permitAll(),denyAll(),isAnonymous(),isRememberMe(),通過(guò)字面意思可以理解isAuthenticated(),isFullyAuthenticated(),這兩個(gè)區(qū)別就是isFullyAuthenticated()對(duì)認(rèn)證的安全要求更高。例如用戶通過(guò)記住密碼功能登入到系統(tǒng)進(jìn)行敏感操作,isFullyAuthenticated()會(huì)返回false,此時(shí)我們可以讓用戶再輸入一次密碼以確保安全,而isAuthenticated()只要是登入用戶均返回true。principal(),authentication(),例如我們想獲取登入用戶的id,可以通過(guò)principal()返回的Object獲取,實(shí)際上principal()返回的Object基本上可以等同我們自己編寫的CustomUser。而authentication()返回的Authentication是Principal的父類,相關(guān)操作可看Authentication的源碼。進(jìn)一步了解可以看后面Controller編寫中獲取用戶數(shù)據(jù)的四種方法hasPermission(),參考字面意思即可
如果想進(jìn)一步了解,可以參考 Intro to Spring Security Expressions。
添加Thymeleaf支持
我們通過(guò) thymeleaf-extras-springsecurity 來(lái)添加Thymeleaf對(duì)Spring Security的支持。
Maven配置
上面的Maven配置已經(jīng)加過(guò)了
<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>
如果想進(jìn)一步了解請(qǐng)看文檔 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
在這個(gè)控制器中,我綜合展示了自定義注解的使用和4種獲取用戶信息的方式
@IsUser // 表明該控制器下所有請(qǐng)求都需要登入后才能訪問
@Controller
@RequestMapping("/user")
public class UserController {
@GetMapping("/home")
public String home(Model model) {
// 方法一:通過(guò)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) {
// 方法二:通過(guò)方法注入的形式獲取Authentication
CustomUser user = (CustomUser)authentication.getPrincipal();
model.addAttribute("user", user);
return "user/editor";
}
@GetMapping("/reviewer")
@IsReviewer
public String reviewer(Principal principal, Model model) {
// 方法三:同樣通過(guò)方法注入的方法,注意要轉(zhuǎn)型,此方法很二,不推薦
CustomUser user = (CustomUser) ((Authentication)principal).getPrincipal();
model.addAttribute("user", user);
return "user/reviewer";
}
@GetMapping("/admin")
@IsAdmin
public String admin() {
// 方法四:通過(guò)Thymeleaf的Security標(biāo)簽進(jìn)行,詳情見admin.html
return "user/admin";
}
}
注意
- 如果有安全控制的方法 A 被同一個(gè)類中別的方法調(diào)用,那么方法 A 的權(quán)限控制會(huì)被忽略,私有方法同樣會(huì)受到影響
- Spring 的
SecurityContext是線程綁定的,如果我們?cè)诋?dāng)前的線程中新建了別的線程,那么他們的SecurityContext是不共享的,進(jìn)一步了解請(qǐng)看 Spring Security Context Propagation with @Async
Html的編寫
在編寫html的時(shí)候,基本上就是大同小異了,就是注意一點(diǎn),**如果開啟了CSRF,在編寫表單POST請(qǐng)求的時(shí)候添加上隱藏字段,如 **<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> ,不過(guò)大家其實(shí)不用加也沒事,因?yàn)門hymeleaf自動(dòng)會(huì)加上去的??。
總結(jié)
教程粗糙,歡迎指正!
如需深入了解,如果想系統(tǒng)的學(xué)習(xí)可以看看 Security with Spring。