一、引入相關依賴
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- Thymeleaf-Security -->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
<version>3.0.4.RELEASE</version>
</dependency>
<!-- Thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- SpringSecurity -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.6</version>
</dependency>
</dependencies>
二、基礎準備
1、準備Pojo類
@Data
public class LcyUser {
private Long id;
private String username;
private String password;
}
2、Service層準備
@Service
public class UserServiceImpl implements UserService {
@Override
public LcyUser findUserByUsername(String username) {
LcyUser user = new LcyUser();
user.setUsername(username);
// 對123加密後的密碼
user.setPassword("$2a$10$XcigeMfToGQ2bqRToFtUi.sG1V.HhrJV6RBjji1yncXReSNNIPl1K");
return user;
}
}
3、Controller層準備
@Controller
public class TestController {
private final String PREFIX = "pages/";
@RequestMapping("/")
public String welcome(){
return "welcome";
}
@GetMapping("/loginHtml")
public String loginPage(){
return PREFIX + "login";
}
@GetMapping("/403")
public String fourZeroThree(){
return PREFIX + "403";
}
/**
* level1頁面映射
* @param path
* @return
*/
@GetMapping("/level1/{path}")
public String level1(@PathVariable("path")String path) {
return PREFIX+"level1/"+path;
}
/**
* level2頁面映射
* @param path
* @return
*/
@GetMapping("/level2/{path}")
public String level2(@PathVariable("path")String path) {
return PREFIX+"level2/"+path;
}
/**
* level3頁面映射
* @param path
* @return
*/
@GetMapping("/level3/{path}")
public String level3(@PathVariable("path")String path) {
return PREFIX+"level3/"+path;
}
/**
* level3頁面映射
* @param path
* @return
*/
@GetMapping("/level4/{path}")
// @Secured("ROLE_ADMIN") // 擁有ADMIN角色可訪問
// @PreAuthorize("hasRole('ROLE_ADMIN') and hasRole('ROLE_VIP1')") // 擁有ADMIN和VIP1可以訪問
// @PreAuthorize("hasAnyRole('ROLE_ADMIN','ROLE_VIP2')") // 擁有其中一個就可以訪問
// @PostAuthorize("hasAnyRole('ROLE_VIP2','ROLE_VIP1')") // 與@PreAuthorize用法差不多
public String level4(@PathVariable("path")String path) {
return PREFIX+"level3/"+path;
}
}
4、前端頁面準備
- 登錄頁面login.html
<body>
<h1 align="center">歡迎登陸英雄聯盟管理系統</h1>
<hr>
<div align="center">
<form th:action="@{/userLogin}" method="post">
用戶名:<input name="username"/><br>
密碼:<input name="password"><br/>
<input type="checkbox" name="remeber"> 記住我<br/>
<input type="submit" value="登陸">
</form>
</div>
</body>
- 主頁面welcome.html
<!DOCTYPE html>
<!-- 注意引入的是SpringSecurity5 -->
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Insert title here</title>
</head>
<body>
<h1 align="center">歡迎光臨英雄聯盟管理系統</h1>
<!-- 沒認證顯示 -->
<div sec:authorize="!isAuthenticated()">
<h2 align="center">遊客您好,如果想查看聯盟 <a th:href="@{/userLogin}">請登錄</a></h2>
</div>
<!-- 認證顯示 -->
<div sec:authorize="isAuthenticated()">
<!-- 賬號與角色 -->
<h2><span sec:authentication="name"></span>,您好,您的角色有:
<span sec:authentication="principal.authorities"></span></h2>
<form th:action="@{/userLogout}" method="post">
<input type="submit" value="註銷"/>
</form>
</div>
<hr>
<!-- 擁有VIP1角色可以訪問 -->
<div sec:authorize="hasRole('VIP1')">
<h3>諾克薩斯陣營</h3>
<ul>
<li><a th:href="@{/level1/1}">德萊厄斯</a></li>
<li><a th:href="@{/level1/2}">卡特琳娜</a></li>
<li><a th:href="@{/level1/3}">塞恩</a></li>
</ul>
</div>
<!-- 擁有VIP2角色可以訪問 -->
<div sec:authorize="hasRole('VIP2')">
<h3>德瑪西亞陣營</h3>
<ul>
<li><a th:href="@{/level2/1}">嘉文四世</a></li>
<li><a th:href="@{/level2/2}">蓋倫</a></li>
<li><a th:href="@{/level2/3}">拉克絲</a></li>
</ul>
</div>
<!-- 擁有VIP3角色可以訪問 -->
<div sec:authorize="hasRole('VIP3')">
<h3>艾歐尼亞陣營</h3>
<ul>
<li><a th:href="@{/level3/1}">亞索</a></li>
<li><a th:href="@{/level3/2}">李青</a></li>
<li><a th:href="@{/level3/3}">艾瑞莉婭</a></li>
</ul>
</div>
</body>
</html>
三、自定義認證類
自定義認證類,用於登錄認證使用,需要從數據庫獲取數據進行認證,並授權。需要實現UserDetailsService
接口。
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private UserService userService;
@Override
public UserDetails loadUserByUsername(String usernmae) throws UsernameNotFoundException {
// 定義權限的集合 - 全部存儲的是權限
List<GrantedAuthority> authorities = new ArrayList<>();
// 權限應該從數據庫中獲取,這裏寫死了,權限必須以ROLE_開頭
authorities.add(new SimpleGrantedAuthority("ROLE_VIP1"));
// 判斷用戶名是否爲空
if (StringUtils.isEmpty(usernmae)) {
return null;
}
// 去數據庫中查找對象
LcyUser lcyUser = userService.findUserByUsername(usernmae);
// 封裝到User對象中去
User user = new User(usernmae,lcyUser.getPassword(),authorities);
return user;
}
}
四、核心配置類
核心配置類,主要對數據來源和加密
、攔截
、登錄/註銷
、記住我
進行了配置。
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true) // 開啓註解配置方法安全
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 配置哪些請求不攔截
*/
@Override
public void configure(WebSercurity web) throws Exception{
web.ignoring().antMatchers("/api/**","/swgger-ui.html");
}
/**
* 定製請求的授權規則
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 訪問權限設置
http.authorizeRequests().antMatchers("/").permitAll() // 任何人都可以訪問
.antMatchers("/level1/**").hasRole("VIP1") // VIP1角色可以訪問
.antMatchers("/level2/**").hasRole("VIP2") // VIP2角色可以訪問
.antMatchers("/level3/**").hasRole("VIP3"); // VIP3角色可以訪問
// .antMatchers("/level4/**")
// .access("hasRole('ADMIN') and hasRole('VIP3')") // 同時擁有ADMIN和VIP3可以訪問
// .antMatchers("/level5/**")
// .access("hasAnyRole('VIP1','VIP3')") // 擁有VIP1或VIP3可以訪問
// .anyRequest().authenticated(); // 表示除去上面的,其他URL必須通過認證纔可以訪問
/**
* 開啓自動配置的登陸功能,如果沒有登陸/沒有權限就會來到登陸頁面
* usernameParameter:指定form表單中賬號的name,默認爲username
* passwordParameter:指定form表單中密碼的name,默認爲password
* loginPage:指定自定義登錄頁,如果不設置,默認爲Security自帶登錄頁
* loginProcessingUrl:指定登錄請求url,默認爲/login
* failureUrl:指定登錄失敗跳轉url,默認爲指定的loginPage?error,即loginHtml?error
*/
http.formLogin().usernameParameter("username").passwordParameter("password")
.loginPage("/loginHtml").loginProcessingUrl("/userLogin")
.failureUrl("/loginHtml?error=true");
/**
* 開啓自動配置的註銷功能
* logoutUrl:註銷請求url,默認爲/logout
* logoutSuccessUrl:註銷成功跳轉rul,默認爲/loginPage?logout,即這裏的loginHtml?logout
* clearAuthentication:是否清除身份認證信息,默認爲true
* invalidateHttpSession:是否使 Session 失效,默認爲true
*/
http.logout().logoutUrl("/userLogout").logoutSuccessUrl("/")
.clearAuthentication(true).invalidateHttpSession(true);
/**
* 開啓記住我功能 - 參數爲記住我複選框的name
* 登陸成功以後,將cookie發給瀏覽器保存,以後訪問頁面帶上這個cookie,只要通過檢查就可以免登錄
* 點擊註銷會刪除cookie
*/
http.rememberMe().rememberMeParameter("remeber");
// 訪問無權限的請求,跳轉的頁面
http.exceptionHandling().accessDeniedPage("/403");
// 上面的登錄成功/失敗、註銷、無權限都可以通過處理類來做
// 成功處理類
// http.formLogin().successHandler(new AuthenticationSuccessHandlerImpl());
// 失敗處理類 - 實現AuthenticationFailureHandler
// http.formLogin().failureForwardUrl(new AuthenticationFailureHandlerImpl());
// 註銷處理類 - 實現LogoutSuccessHandler
// http.logout().addLogoutHandler(new LogoutSuccessHandlerImpl());
// 無權限處理類
// http.exceptionHandling().accessDeniedHandler(new AccessDeniedHandler());
}
/**
* 設置自定義數據源及加密方式
*/
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception{
builder.userDetailsService(userDetailService())
.passwordEncoder(passwordEncoder());
}
/**
* 自定義數據源
* @return
*/
@Bean
public UserDetailServiceImpl userDetailService(){
return new UserDetailServiceImpl();
}
/**
* 加密方法 - BCrypt
* @return
*/
@Bean
public BCryptPasswordEncoder passwordEncoder(){
/**
* 可選參數:strength
* strength 越大,密鑰的迭代次數越多,密鑰的迭代次數爲2的strength次方
* strength 取值在 4-31 之間,默認爲10(不寫參數的情況下)
*/
return new BCryptPasswordEncoder();
}
}
五、自定義登錄成功/失敗處理類
這裏以成功示例:
public class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
System.out.println("登錄成功");
// 手動跳轉
httpServletResponse.sendRedirect("/admin/index.html"); // 重定向
/*
httpServletRequest.getRequestDispatcher("/admin/index.html")
.forward(httpServletRequest,httpServletResponse); // 請求轉發
*/
}
}
六、角色繼承
前面所定義的VIP1
、VIP2
、VIP3
,它們之間是沒有任何關係的。但是,有時候我們需要這樣一個需求。VIP1
擁有VIP2
的權限,VIP2
擁有VIP3
的權限,這時我們可以通過角色繼承
的方式來做。我們只需要在SpringSecurity
的配置文件裏添加一個RoleHierarchy
的方法即可。
@Bean
public RoleHierarchy roleHierarchy(){
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
// 角色繼承 - VIP1擁有VIP2的角色,VIP2擁有VIP3的角色
String hierarchy = "ROLE_VIP1 > ROLE_VIP2 ROLE_VIP2 > ROLE_VIP3";
roleHierarchy.setHierarchy(hierarchy);
return roleHierarchy;
}
但是,經過我測試,在Thymeleaf
頁面使用類似sec:authorize="hasRole('VIP3')"
的標籤,VIP1
即使擁有VIP3
的角色,它也是無法看到標籤所標記的內容的,但是卻是可以訪問VIP3
所能訪問的URL
。
動態配置權限
如果覺得基於HttpSecurity
配置的認證授權規則不夠靈活,無法實現資源與角色之間的動態調整。要實現動態配置URL權限,需要自己自定義權限配置。可以在數據配置,賬戶與角色之間的關係,角色與URL的關係來完成。
ROLE_VIP1 /level1/**
ROLE_VIP2 /level2/**
一、準備工作
1、準備POJO類
@Data
public class Role {
private Integer id;
/** 角色ROLE */
private String name;
/** 角色名,如經理、HR */
private String rname;
public Role(Integer id, String name, String rname) {
this.id = id;
this.name = name;
this.rname = rname;
}
}
@Data
public class Menu {
private Integer id;
/** 可訪問url */
private String pattern;
/** 可以訪問的角色有哪些 */
private List<Role> roles;
public Menu(Integer id, String pattern) {
this.id = id;
this.pattern = pattern;
}
}
2、準備Service類
@Service
public class UserServiceImpl implements UserService {
@Override
public List<Menu> getAllMenus() {
// URL和角色都應該從數據庫獲取,這裏爲了方便,寫死了
// URL
Menu menu1 = new Menu(1,"/level1/**");
Menu menu2 = new Menu(2,"/level2/**");
List<Menu> list = new ArrayList<>();
// 角色
Role role1 = new Role(1,"ROLE_VIP1","VIP1玩家");
Role role2 = new Role(1,"ROLE_VIP1","VIP1玩家");
List<Role> roles = new ArrayList<>();
roles.add(role1);
roles.add(role2);
// 角色與URL關係
menu1.setRoles(roles);
menu1.setRoles(roles);
list.add(menu1);
list.add(menu2);
return list;
}
二、自定義FilterInvocationSecurityMetadataSource
配置了動態權限,一個請求會先經過這個類的getAttributes
方法進行判斷返回角色。
// 一個請求先走FilterInvocationSecurityMetadataSource,然後再走AccessDecisionManager
public class MyFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
@Autowired
private UserService userService;
AntPathMatcher antPathMatcher = new AntPathMatcher();
/**
* 可以從FilterInvocation中獲取當前請求的URL
* @return URL所需角色
* @throws IllegalArgumentException
*/
@Override
public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
// 獲取資源信息Menu,建議放在Redis等緩存數據庫中
List<Menu> allMenus = userService.getAllMenus();
// 獲取當前請求的URL
String requestUrl = ((FilterInvocation) o).getRequestUrl();
// 所有人都可以訪問的URL,做特殊處理
if("/".equals(requestUrl) || "/loginHtml".equals(requestUrl) || "/loginHtml?error=true".equals(requestUrl)){
return SecurityConfig.createList("ROLE_ALL");
}
// 遍歷資源信息
for (Menu menu : allMenus) {
if (antPathMatcher.match(menu.getPattern(),requestUrl)){
List<Role> roles = menu.getRoles();
// 獲取所有角色名 類似於['VIP1','VIP2','VIP3']
String[] roleArr = new String[roles.size()];
for (int i = 0; i < roleArr.length; i++){
roleArr[i] = roles.get(i).getName();
}
return SecurityConfig.createList(roleArr);
}
}
// 如果不存在相應的模式,直接返回ROLE_LOGIN,表示登錄就可以訪問
return SecurityConfig.createList("ROLE_LOGIN");
}
/**
* 返回定義好的權限資源
* SpringSecurity啓動時會校驗相關配置是否正確
* 如果不需要校驗,直接返回null
*/
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
/**
* 返回類對象是否支持校驗
*/
@Override
public boolean supports(Class<?> aClass) {
return FilterInvocation.class.isAssignableFrom(aClass);
}
}
三、自定義AccessDecisionManager
當一個請求走完FilterInvocationSecurityMetadataSource
之後就會來到這裏,這裏主要是判斷是否有權限的,有權限纔會正常走請求流程。
// 一個請求先走FilterInvocationSecurityMetadataSource,然後再走AccessDecisionManager
@Component
public class MyAccessDecisionManager implements AccessDecisionManager {
/**
* 判斷當前登錄的用戶是否具備當前請求URL所需要的角色信息
* 如不具備拋出AccessDeniedException異常
* 否則不做任何事
* @param authentication 當前登錄用戶信息
* @param o FilterInvocation對象,可以獲取當前請求對象
* @param collection 請求當前URL所需要角色
* @throws AccessDeniedException
* @throws InsufficientAuthenticationException
*/
@Override
public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
for (ConfigAttribute configAttribute : collection) {
// 對所有人可訪問做處理
if("ROLE_ALL".equals(configAttribute.getAttribute())){
return;
}
// 登錄即可訪問
if("ROLE_LOGIN".equals(configAttribute.getAttribute()) && authentication instanceof UsernamePasswordAuthenticationToken){
return;
}
// 角色對比
for (GrantedAuthority authority : authorities) {
if(configAttribute.getAttribute().equals(authority.getAuthority())){
return;
}
}
}
// 可以通過處理該異常來進行跳轉 - 配置http.exceptionHandling()
throw new AccessDeniedException("權限不足");
}
@Override
public boolean supports(ConfigAttribute configAttribute) {
return true;
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
四、配置文件
把上面配置文件靜態的http.authorizeRequests().antMatches
這些代碼替換成動態的http.authorizeRequests().withObjectPostProcessor
並且將兩個類注入進來即可,其他不用變。
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 定製請求的授權規則
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setSecurityMetadataSource(mfisms());
o.setAccessDecisionManager(madm());
return o;
}
});
@Bean
public MyFilterInvocationSecurityMetadataSource mfisms(){
return new MyFilterInvocationSecurityMetadataSource();
}
@Bean
public MyAccessDecisionManager madm(){
return new MyAccessDecisionManager();
}
注意事項:
動態權限,我目前還沒使用過,經過測試。發現下面代碼類似於antMatchers("/").permitAll()
匹配的URL,實際是不生效的,還是要通過withObjectPostProcessor
來處理,也就是說大家都可以訪問的請求,需要在FilterInvocationSecurityMetadataSource
的getAttributes
方法中作處理。而且,我上面自定義的錯誤跳轉URL,即http.formLogin().failureUrl("/loginHtml?error=true")
也會進入withObjectPostProcessor
,這就讓我很鬱悶了,百度了很久未解決。
http.authorizeRequests().antMatchers("/").permitAll()
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setSecurityMetadataSource(mfisms());
o.setAccessDecisionManager(madm());
return o;
}
});
也就是說靜態資源
同樣也會經過這個withObjectPostProcessor
,而且經過測試,發現角色繼承
同樣失效。
我思考了一會兒,想來只能把靜態資源
和大家都能訪問的URL
賦給一個角色
,每個用戶都有這個共有的角色
即可。如果用戶在沒有登錄的情況下訪問,即這時因爲沒有登錄,也就沒有角色
,那就只能在FilterInvocationSecurityMetadataSource
的getAttributes
方法裏想辦法給它安排這個公共角色或者在AccessDecisionManager
裏處理也行。
當然這些都只是我個人的處理方法,因爲技術水平未達到,也沒有閱讀過源碼(水平不夠),只能這樣猜測+實驗式處理了。
如果後面遇到需要處理這種問題且找到解決方案,會更新該博文。