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。
如果有疑問(wèn)拌牲,請(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è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.class
Spring 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ō)明 | 可訪問(wèn)權(quán)限 |
---|---|---|
/ | 首頁(yè) | 所有人均可訪問(wèn)(anonymous) |
/login | 登入頁(yè)面 | 所有人均可訪問(wèn)(anonymous) |
/logout | 退出頁(yè)面 | 所有人均可訪問(wèn)(anonymous) |
/user/home | 用戶中心 | user |
/user/editor | editor, admin | |
/user/reviewer | reviewer, admin | |
/user/admin | admin | |
/403 | 403錯(cuò)誤頁(yè)面潭袱,美化過(guò),大家可以直接用 | 所有人均可訪問(wèn)(anonymous) |
/404 | 404錯(cuò)誤頁(yè)面锋恬,美化過(guò)屯换,大家可以直接用 | 所有人均可訪問(wèn)(anonymous) |
/500 | 500錯(cuò)誤頁(yè)面,美化過(guò)与学,大家可以直接用 | 所有人均可訪問(wèn)(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)允許所有路徑所有人都可以訪問(wèn)澎现,確保靜態(tài)資源的正常訪問(wèn)渠欺。
* 后面再通過(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ù)的方式獲取费封,則沒有這種問(wèn)題需要擔(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)求都需要登入后才能訪問(wèn)
@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。