本文是一個筆記系列,目標是完成一個基於角色的許可權訪問控制系統(RBAC),有基本的使用者、角色、許可權管理,重點在Spring Security的各種配置。萬丈高樓平地起,接下來,一步一步,由淺入深,希望給一起學習的小夥伴一個參考。
1. Hello Security
按照慣例,先寫個Hello World
首先,引入依賴
1 <dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-security</artifactId>
4 </dependency>
先來理清楚“認證”和“授權”兩個概念。認證就是告訴我你是誰,授權就是你可以做什麼。結合實際專案通俗地來講,認證就是登入,授權就是訪問資源。故而,我們需要先有使用者和資源,先簡單地定義幾個記憶體使用者和資源吧,為此需要在WebSecurtiyConfigurerAdapter中進行配置。
WebSecurityConfig.java
1 package com.example.demo.config;
2
3 import org.springframework.context.annotation.Bean;
4 import org.springframework.context.annotation.Configuration;
5 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
6 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
7 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
8 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
9 import org.springframework.security.crypto.password.PasswordEncoder;
10
11 @Configuration
12 public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
13
14 @Override
15 protected void configure(AuthenticationManagerBuilder auth) throws Exception {
16 auth.inMemoryAuthentication()
17 .withUser("zhangsan").password(passwordEncoder().encode("123456")).roles("user")
18 .and()
19 .withUser("admin").password(passwordEncoder().encode("123456")).roles("admin")
20 .and()
21 .passwordEncoder(passwordEncoder());
22 }
23
24 @Override
25 protected void configure(HttpSecurity http) throws Exception {
26 http.formLogin()
27 // .loginPage("/login.html")
28 .loginProcessingUrl("/login")
29 .usernameParameter("username")
30 .passwordParameter("password")
31 .defaultSuccessUrl("/")
32 .and()
33 .authorizeRequests()
34 .antMatchers("/login.html", "/login").permitAll()
35 .antMatchers("/hello/sayHello").hasAnyAuthority("ROLE_user", "ROLE_admin")
36 .antMatchers("/hello/sayHi").hasAnyRole("admin")
37 .anyRequest().authenticated();
38 }
39
40 @Bean
41 public PasswordEncoder passwordEncoder() {
42 return new BCryptPasswordEncoder();
43 }
44 }
HelloController.java
1 package com.example.demo.controller;
2
3 import org.springframework.web.bind.annotation.GetMapping;
4 import org.springframework.web.bind.annotation.RequestMapping;
5 import org.springframework.web.bind.annotation.RestController;
6
7 @RestController
8 @RequestMapping("/hello")
9 public class HelloController {
10
11 @GetMapping("/sayHello")
12 public String sayHello() {
13 return "hello";
14 }
15
16 @GetMapping("/sayHi")
17 public String sayHi() {
18 return "hi";
19 }
20
21 }
專案結構
定義了兩個使用者zhangsan和admin,他們的密碼都是123456,zhangsan的角色是user可以訪問/hello/sayHello,admin的角色是admin可以訪問/hello/sayHello和hello/sayHi
2. 認證成功/失敗處理
按照剛才的寫法,登入成功之後是跳到/頁面,失敗跳轉到登入頁。但是,對於前後端分離的專案,我希望它返回json資料,而不是重定向到某個頁面
處理使用者名稱和密碼登入的過濾器是org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter,既然是過濾器,直接看doFilter方法
不用多說,自定義認證成功處理器
1 package com.example.demo.handler;
2
3 import com.fasterxml.jackson.databind.ObjectMapper;
4 import org.springframework.security.core.Authentication;
5 import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
6 import org.springframework.stereotype.Component;
7
8 import javax.servlet.ServletException;
9 import javax.servlet.http.HttpServletRequest;
10 import javax.servlet.http.HttpServletResponse;
11 import java.io.IOException;
12
13 @Component
14 public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
15
16 private static ObjectMapper objectMapper = new ObjectMapper();
17
18 @Override
19 public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
20 response.setContentType("application/json;charset=utf-8");
21 response.getWriter().write(objectMapper.writeValueAsString("ok"));
22 }
23 }
自定義認證失敗處理器
1 package com.example.demo.handler;
2
3 import com.fasterxml.jackson.databind.ObjectMapper;
4 import org.springframework.security.core.AuthenticationException;
5 import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
6 import org.springframework.stereotype.Component;
7
8 import javax.servlet.ServletException;
9 import javax.servlet.http.HttpServletRequest;
10 import javax.servlet.http.HttpServletResponse;
11 import java.io.IOException;
12
13 @Component
14 public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
15
16 private static ObjectMapper objectMapper = new ObjectMapper();
17
18 @Override
19 public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
20 response.setContentType("application/json;charset=utf-8");
21 response.getWriter().write(objectMapper.writeValueAsString("error"));
22 }
23 }
WebSecurityConfig配置
1 package com.example.demo.config;
2
3 import com.example.demo.handler.MyAuthenticationFailureHandler;
4 import com.example.demo.handler.MyAuthenticationSuccessHandler;
5 import com.example.demo.handler.MyExpiredSessionStrategy;
6 import org.springframework.beans.factory.annotation.Autowired;
7 import org.springframework.context.annotation.Bean;
8 import org.springframework.context.annotation.Configuration;
9 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
10 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
11 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
12 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
13 import org.springframework.security.crypto.password.PasswordEncoder;
14
15 @Configuration
16 public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
17
18 @Autowired
19 private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
20 @Autowired
21 private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
22
23 @Override
24 protected void configure(AuthenticationManagerBuilder auth) throws Exception {
25 auth.inMemoryAuthentication()
26 .withUser("zhangsan").password(passwordEncoder().encode("123456")).roles("user")
27 .and()
28 .withUser("admin").password(passwordEncoder().encode("123456")).roles("admin")
29 .and()
30 .passwordEncoder(passwordEncoder());
31 }
32
33 @Override
34 protected void configure(HttpSecurity http) throws Exception {
35 http.formLogin()
36 // .loginPage("/login.html")
37 .loginProcessingUrl("/login")
38 .usernameParameter("username")
39 .passwordParameter("password")
40 // .defaultSuccessUrl("/")
41 .successHandler(myAuthenticationSuccessHandler)
42 .failureHandler(myAuthenticationFailureHandler)
43 .and()
44 .authorizeRequests()
45 .antMatchers("/login.html", "/login").permitAll()
46 .antMatchers("/hello/sayHello").hasAnyAuthority("ROLE_user", "ROLE_admin")
47 .antMatchers("/hello/sayHi").hasAnyRole("admin")
48 .anyRequest().authenticated()
49 .and()
50 .sessionManagement().sessionFixation().migrateSession()
51 .maximumSessions(1).maxSessionsPreventsLogin(false).expiredSessionStrategy(new MyExpiredSessionStrategy());
52 }
53
54 @Bean
55 public PasswordEncoder passwordEncoder() {
56 return new BCryptPasswordEncoder();
57 }
58 }
再多自定義一個Session過期策略,當Session過期或者被踢下線以後的處理邏輯
1 package com.example.demo.handler;
2
3 import com.fasterxml.jackson.databind.ObjectMapper;
4 import org.springframework.security.web.session.SessionInformationExpiredEvent;
5 import org.springframework.security.web.session.SessionInformationExpiredStrategy;
6
7 import javax.servlet.ServletException;
8 import javax.servlet.http.HttpServletResponse;
9 import java.io.IOException;
10
11 public class MyExpiredSessionStrategy implements SessionInformationExpiredStrategy {
12
13 private static ObjectMapper objectMapper = new ObjectMapper();
14
15 @Override
16 public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
17 String msg = "登入超時或已在另一臺機器登入,您被迫下線!";
18 HttpServletResponse response = event.getResponse();
19 response.setContentType("application/json;charset=utf-8");
20 response.getWriter().write(objectMapper.writeValueAsString(msg));
21 }
22 }
3. 從資料庫中載入使用者及許可權
剛才使用者是在記憶體中定義的,這肯定是不行的,下面從資料庫中載入使用者及其所擁有的許可權
最簡單的結構是這樣的:
為了減少使用者的重複授權,引入使用者組。將使用者加入使用者組以後,就自動擁有組所對應的許可權。
下面,按照最簡單的使用者角色許可權模型來改造剛才的專案
首先,通過實現UserDetails介面來自定義一個使用者資訊物件
MyUserDetails.java
1 package com.example.demo.model;
2
3 import org.springframework.security.core.GrantedAuthority;
4 import org.springframework.security.core.userdetails.UserDetails;
5
6 import java.util.Collection;
7
8 public class MyUserDetails implements UserDetails {
9
10 private String username;
11 private String password;
12 private boolean enabled;
13 private Collection<? extends GrantedAuthority> authorities;
14
15 public MyUserDetails(String username, String password, boolean enabled) {
16 this.username = username;
17 this.password = password;
18 this.enabled = enabled;
19 }
20
21 public void setUsername(String username) {
22 this.username = username;
23 }
24
25 public void setPassword(String password) {
26 this.password = password;
27 }
28
29 public void setEnabled(boolean enabled) {
30 this.enabled = enabled;
31 }
32
33 public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
34 this.authorities = authorities;
35 }
36
37 @Override
38 public Collection<? extends GrantedAuthority> getAuthorities() {
39 return authorities;
40 }
41
42 @Override
43 public String getPassword() {
44 return password;
45 }
46
47 @Override
48 public String getUsername() {
49 return username;
50 }
51
52 @Override
53 public boolean isAccountNonExpired() {
54 return true;
55 }
56
57 @Override
58 public boolean isAccountNonLocked() {
59 return true;
60 }
61
62 @Override
63 public boolean isCredentialsNonExpired() {
64 return true;
65 }
66
67 @Override
68 public boolean isEnabled() {
69 return enabled;
70 }
71 }
有了UserDetails以後,還需要UserDetailsService去載入它,所以自定義一個UserDetailsService
MyUserDetailsService.java
1 package com.example.demo.service;
2
3 import com.example.demo.entity.*;
4 import com.example.demo.model.MyUserDetails;
5 import com.example.demo.repository.*;
6 import org.springframework.beans.factory.annotation.Autowired;
7 import org.springframework.security.core.GrantedAuthority;
8 import org.springframework.security.core.authority.SimpleGrantedAuthority;
9 import org.springframework.security.core.userdetails.UserDetails;
10 import org.springframework.security.core.userdetails.UserDetailsService;
11 import org.springframework.security.core.userdetails.UsernameNotFoundException;
12 import org.springframework.stereotype.Component;
13
14 import java.util.ArrayList;
15 import java.util.List;
16 import java.util.Optional;
17 import java.util.stream.Collectors;
18
19 @Component
20 public class MyUserDetailsService implements UserDetailsService {
21
22 @Autowired
23 private SysUserRepository sysUserRepository;
24 @Autowired
25 private SysRoleRepository sysRoleRepository;
26 @Autowired
27 private SysUserRoleRelationRepository sysUserRoleRelationRepository;
28 @Autowired
29 private SysRolePermissionRelationRepository sysRolePermissionRelationRepository;
30 @Autowired
31 private SysPermissionRepository sysPermissionRepository;
32
33 @Override
34 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
35 // 查使用者
36 Optional<SysUser> optionalSysUser = sysUserRepository.findByUsername(username);
37 SysUser sysUser = optionalSysUser.orElseThrow(()->new UsernameNotFoundException("使用者名稱" + username + "不存在"));
38
39 // 查許可權
40 List<SysUserRoleRelation> sysUserRoleRelationList = sysUserRoleRelationRepository.findByUserId(sysUser.getId());
41 List<Integer> roleIds = sysUserRoleRelationList.stream().map(SysUserRoleRelation::getRoleId).collect(Collectors.toList());
42 List<SysRole> sysRoleList = sysRoleRepository.findByIdIn(roleIds);
43 List<SysRolePermissionRelation> sysRolePermissionRelationList = sysRolePermissionRelationRepository.findByRoleIdIn(roleIds);
44 List<Integer> permissionIds = sysRolePermissionRelationList.stream().map(SysRolePermissionRelation::getPermissionId).collect(Collectors.toList());
45 List<SysPermission> sysPermissionList = sysPermissionRepository.findByIdIn(permissionIds);
46
47 List<GrantedAuthority> grantedAuthorities = new ArrayList<>(sysPermissionList.size());
48 for (SysPermission permission : sysPermissionList) {
49 grantedAuthorities.add(new SimpleGrantedAuthority(permission.getUrl()));
50 }
51 sysRoleList.forEach(role->grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleCode())));
52
53 MyUserDetails myUserDetails = new MyUserDetails(sysUser.getUsername(), sysUser.getPassword(), sysUser.isEnabled());
54 myUserDetails.setAuthorities(grantedAuthorities);
55
56 return myUserDetails;
57 }
58 }
這裡用的JPA,相關的實體類及Repository太多就不一一貼出來了,只代表性的貼一個
SysRole.java
1 package com.example.demo.entity;
2
3 import lombok.Data;
4
5 import javax.persistence.*;
6 import java.io.Serializable;
7 import java.time.LocalDateTime;
8
9 @Data
10 @Entity
11 @Table(name = "sys_role")
12 public class SysRole implements Serializable {
13
14 @Id
15 @GeneratedValue(strategy = GenerationType.AUTO)
16 private Integer id;
17
18 private String roleName;
19
20 private String roleCode;
21
22 private String roleDesc;
23
24 private LocalDateTime createTime;
25
26 private LocalDateTime updateTime;
27 }
SysRoleRepository.java
1 package com.example.demo.repository;
2
3 import com.example.demo.entity.SysRole;
4 import org.springframework.data.jpa.repository.JpaRepository;
5
6 import java.util.List;
7
8 public interface SysRoleRepository extends JpaRepository<SysRole, Integer> {
9
10 List<SysRole> findByIdIn(List<Integer> ids);
11 }
application.properties
1 spring.datasource.url=jdbc:mysql://localhost:3306/test
2 spring.datasource.username=root
3 spring.datasource.password=123456
4 spring.datasource.driver-class-name=com.mysql.jdbc.Driver
5
6 spring.jpa.database=mysql
最後,也是最重要的是配置WebSecurity
WebSecurityConfig.java
1 package com.example.demo.config;
2
3 import com.example.demo.handler.MyAuthenticationFailureHandler;
4 import com.example.demo.handler.MyAuthenticationSuccessHandler;
5 import com.example.demo.handler.MyExpiredSessionStrategy;
6 import com.example.demo.service.MyUserDetailsService;
7 import org.springframework.beans.factory.annotation.Autowired;
8 import org.springframework.context.annotation.Bean;
9 import org.springframework.context.annotation.Configuration;
10 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
11 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
12 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
13 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
14 import org.springframework.security.crypto.password.PasswordEncoder;
15
16 @Configuration
17 public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
18
19 @Autowired
20 private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
21 @Autowired
22 private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
23 @Autowired
24 private MyUserDetailsService myUserDetailsService;
25
26 @Override
27 protected void configure(AuthenticationManagerBuilder auth) throws Exception {
28 auth.userDetailsService(myUserDetailsService).passwordEncoder(passwordEncoder());
29 }
30
31 @Override
32 protected void configure(HttpSecurity http) throws Exception {
33 http.formLogin()
34 .loginProcessingUrl("/login")
35 .usernameParameter("username")
36 .passwordParameter("password")
37 .successHandler(myAuthenticationSuccessHandler)
38 .failureHandler(myAuthenticationFailureHandler)
39 .and()
40 .authorizeRequests()
41 .antMatchers("/login.html", "/login").permitAll()
42 .antMatchers("/hello/sayHello").hasAnyAuthority("ROLE_user", "ROLE_admin")
43 .antMatchers("/hello/sayHi").hasAnyRole("admin")
44 .anyRequest().authenticated()
45 .and()
46 .sessionManagement().sessionFixation().migrateSession()
47 .maximumSessions(1).maxSessionsPreventsLogin(false).expiredSessionStrategy(new MyExpiredSessionStrategy());
48 }
49
50 @Bean
51 public PasswordEncoder passwordEncoder() {
52 return new BCryptPasswordEncoder();
53 }
54
55 }
改完後的專案結構如下
4. 動態載入許可權規則配置
鑑權規則就是判斷請求的資源是不是在當前使用者可訪問的資源列表中
那麼,首先,定義一個方法來實現這個邏輯
1 package com.example.demo.service;
2
3 import org.springframework.security.core.Authentication;
4 import org.springframework.security.core.authority.SimpleGrantedAuthority;
5 import org.springframework.security.core.userdetails.UserDetails;
6 import org.springframework.stereotype.Component;
7
8 import javax.servlet.http.HttpServletRequest;
9
10 @Component("myAccessDecisionService")
11 public class MyAccessDecisionService {
12
13 public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
14 Object principal = authentication.getPrincipal();
15 if (principal instanceof UserDetails) {
16 UserDetails userDetails = (UserDetails) principal;
17 SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(request.getRequestURI());
18 return userDetails.getAuthorities().contains(simpleGrantedAuthority);
19 }
20 return false;
21 }
22 }
然後,在WebSecurityConfig中配置,替換原來寫死的匹配規則
1 package com.example.demo.config;
2
3 import com.example.demo.handler.MyAuthenticationFailureHandler;
4 import com.example.demo.handler.MyAuthenticationSuccessHandler;
5 import com.example.demo.handler.MyExpiredSessionStrategy;
6 import com.example.demo.service.MyUserDetailsService;
7 import org.springframework.beans.factory.annotation.Autowired;
8 import org.springframework.context.annotation.Bean;
9 import org.springframework.context.annotation.Configuration;
10 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
11 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
12 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
13 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
14 import org.springframework.security.crypto.password.PasswordEncoder;
15
16 @Configuration
17 public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
18
19 @Autowired
20 private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
21 @Autowired
22 private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
23 @Autowired
24 private MyUserDetailsService myUserDetailsService;
25
26 @Override
27 protected void configure(AuthenticationManagerBuilder auth) throws Exception {
28 auth.userDetailsService(myUserDetailsService)
29 .passwordEncoder(passwordEncoder());
30 }
31
32 @Override
33 protected void configure(HttpSecurity http) throws Exception {
34 http.formLogin()
35 .loginProcessingUrl("/login")
36 .usernameParameter("username")
37 .passwordParameter("password")
38 .successHandler(myAuthenticationSuccessHandler)
39 .failureHandler(myAuthenticationFailureHandler)
40 .and()
41 .authorizeRequests()
42 .antMatchers("/login.html", "/login").permitAll()
43 .anyRequest().access("@myAccessDecisionService.hasPermission(request, authentication)")
44 .and()
45 .sessionManagement().sessionFixation().migrateSession()
46 .maximumSessions(1).maxSessionsPreventsLogin(false).expiredSessionStrategy(new MyExpiredSessionStrategy());
47 }
48
49 @Bean
50 public PasswordEncoder passwordEncoder() {
51 return new BCryptPasswordEncoder();
52 }
53
54 }
改造後的專案結構如下
關於許可權(資源)訪問規則,還有一種寫法,這種方式是我在網上看到的,就是利用 FilterInvocationSecurityMetadataSource 和 AccessDecisionManager
這裡我稍微改了一下,先來建立兩個實現類
首先是MyFilterInvocationSecurityMetadataSource.java
1 package com.example.demo.service;
2
3 import com.example.demo.entity.SysPermission;
4 import com.example.demo.repository.SysPermissionRepository;
5 import org.springframework.beans.factory.annotation.Autowired;
6 import org.springframework.security.access.ConfigAttribute;
7 import org.springframework.security.access.SecurityConfig;
8 import org.springframework.security.web.FilterInvocation;
9 import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
10 import org.springframework.stereotype.Component;
11 import org.springframework.util.AntPathMatcher;
12 import org.springframework.util.CollectionUtils;
13
14 import java.util.Collection;
15 import java.util.List;
16 import java.util.stream.Collectors;
17
18 @Component
19 public class MyFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
20
21 private AntPathMatcher pathMatcher = new AntPathMatcher();
22
23 @Autowired
24 private SysPermissionRepository sysPermissionRepository;
25
26 @Override
27 public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
28 String requestUrl = ((FilterInvocation) object).getRequestUrl();
29
30 // 查詢與當前請求URL匹配的所有許可權
31 List<SysPermission> sysPermissionList = sysPermissionRepository.findAll();
32 List<String> urls = sysPermissionList.stream()
33 .map(SysPermission::getUrl)
34 .filter(e->pathMatcher.match(e, requestUrl))
35 .distinct()
36 .collect(Collectors.toList());
37
38 if (!CollectionUtils.isEmpty(urls)) {
39 return SecurityConfig.createList(urls.toArray(new String[urls.size()]));
40 }
41
42 return SecurityConfig.createList("ROLE_login");
43 }
44
45 @Override
46 public Collection<ConfigAttribute> getAllConfigAttributes() {
47 return null;
48 }
49
50 @Override
51 public boolean supports(Class<?> clazz) {
52 return true;
53 }
54 }
MyAccessDecisionManager.java
1 package com.example.demo.service;
2
3 import org.springframework.security.access.AccessDecisionManager;
4 import org.springframework.security.access.AccessDeniedException;
5 import org.springframework.security.access.ConfigAttribute;
6 import org.springframework.security.authentication.AnonymousAuthenticationToken;
7 import org.springframework.security.authentication.InsufficientAuthenticationException;
8 import org.springframework.security.core.Authentication;
9 import org.springframework.security.core.GrantedAuthority;
10 import org.springframework.security.web.FilterInvocation;
11 import org.springframework.stereotype.Component;
12
13 import java.util.Collection;
14 import java.util.List;
15 import java.util.stream.Collectors;
16
17 @Component
18 public class MyAccessDecisionManager implements AccessDecisionManager {
19
20 /**
21 *
22 * @param authentication 當前登入使用者,可以獲取使用者的許可權列表
23 * @param object FilterInvocation物件,可以獲取請求url
24 * @param configAttributes
25 * @throws AccessDeniedException
26 * @throws InsufficientAuthenticationException
27 */
28 @Override
29 public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
30 String requestUrl = ((FilterInvocation) object).getRequestUrl();
31 System.out.println(requestUrl);
32
33 // 當前使用者擁有的許可權(能訪問的資源)
34 Collection<? extends GrantedAuthority> grantedAuthorities = authentication.getAuthorities();
35 List<String> authorities = grantedAuthorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList());
36
37 /*if (!authorities.contains(requestUrl)) {
38 throw new AccessDeniedException("許可權不足");
39 }*/
40
41 // 判斷訪問當前資源所需要的許可權使用者是否擁有
42 // PS: 在我看來,其實就是看兩個集合是否有交集
43
44 for (ConfigAttribute configAttribute : configAttributes) {
45 String attr = configAttribute.getAttribute();
46 if ("ROLE_login".equals(attr)) {
47 if (authentication instanceof AnonymousAuthenticationToken) {
48 throw new AccessDeniedException("非法請求");
49 }
50 }
51
52 if (authorities.contains(attr)) {
53 return;
54 }
55 }
56
57 throw new AccessDeniedException("許可權不足");
58 }
59
60 @Override
61 public boolean supports(ConfigAttribute attribute) {
62 return true;
63 }
64
65 @Override
66 public boolean supports(Class<?> clazz) {
67 return true;
68 }
69 }
最後是WebSecurityConfig
1 package com.example.demo.config;
2
3 import com.example.demo.handler.MyAccessDeniedHandler;
4 import com.example.demo.handler.MyAuthenticationFailureHandler;
5 import com.example.demo.handler.MyAuthenticationSuccessHandler;
6 import com.example.demo.handler.MyExpiredSessionStrategy;
7 import com.example.demo.service.MyAccessDecisionManager;
8 import com.example.demo.service.MyFilterInvocationSecurityMetadataSource;
9 import com.example.demo.service.MyUserDetailsService;
10 import org.springframework.beans.factory.annotation.Autowired;
11 import org.springframework.context.annotation.Bean;
12 import org.springframework.context.annotation.Configuration;
13 import org.springframework.security.config.annotation.ObjectPostProcessor;
14 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
15 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
16 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
17 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
18 import org.springframework.security.crypto.password.PasswordEncoder;
19 import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
20
21 @Configuration
22 public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
23
24 @Autowired
25 private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
26 @Autowired
27 private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
28 @Autowired
29 private MyAccessDeniedHandler myAccessDeniedHandler;
30 @Autowired
31 private MyUserDetailsService myUserDetailsService;
32 @Autowired
33 private MyFilterInvocationSecurityMetadataSource myFilterInvocationSecurityMetadataSource;
34 @Autowired
35 private MyAccessDecisionManager myAccessDecisionManager;
36
37 @Override
38 protected void configure(AuthenticationManagerBuilder auth) throws Exception {
39 auth.userDetailsService(myUserDetailsService)
40 .passwordEncoder(passwordEncoder());
41 }
42
43 @Override
44 protected void configure(HttpSecurity http) throws Exception {
45 http.formLogin()
46 .loginProcessingUrl("/login")
47 .usernameParameter("username")
48 .passwordParameter("password")
49 .defaultSuccessUrl("/")
50 .successHandler(myAuthenticationSuccessHandler)
51 .failureHandler(myAuthenticationFailureHandler)
52 .and()
53 .authorizeRequests()
54 .antMatchers("/login.html", "/login").permitAll()
55 .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
56 @Override
57 public <O extends FilterSecurityInterceptor> O postProcess(O object) {
58 object.setSecurityMetadataSource(myFilterInvocationSecurityMetadataSource);
59 object.setAccessDecisionManager(myAccessDecisionManager);
60 return object;
61 }
62 })
63 .and()
64 .exceptionHandling().accessDeniedHandler(myAccessDeniedHandler)
65 .and()
66 .sessionManagement().sessionFixation().migrateSession()
67 .maximumSessions(1).maxSessionsPreventsLogin(false).expiredSessionStrategy(new MyExpiredSessionStrategy());
68 }
69
70 @Bean
71 public PasswordEncoder passwordEncoder() {
72 return new BCryptPasswordEncoder();
73 }
74
75 }
可以看到,FilterInvocationSecurityMetadataSource的作用就是查詢當前請求的資源所對應許可權,然後將所需的訪問許可權列表傳給AccessDecisionManager;MyAccessDecisionManager的作用是判斷使用者是否有許可權訪問,判斷的依據就是當前資源所對應的許可權是否在使用者所擁有的許可權列表中。
在我看來,就是判斷兩個集合是否有交集,有交集就有許可權訪問,否則沒有許可權訪問
而且,這種方式的許可權在表設計上應該是分了url和許可權編碼的,也就是說許可權識別符號是code,不是url。首先,用請求url去匹配許可權表,找到與之匹配的許可權code,後續所有的許可權比較都是比較的許可權code。這樣其實也挺好。
還有一點,注意到com.example.demo.service.MyAccessDecisionManager#decide()方法有三個引數,第一個引數代表當前登入使用者,第二個引數代表使用者請求,第三個引數代表訪問資源所需的許可權。
本例中,用的是第一和第三個引數
但是,我覺得可以直接用第一和第二個引數,使用者請求也能拿到,使用者許可權也能拿到,有這些就可以判斷使用者是否有許可權了,這樣的話只需要AccessDecisionManager,而不需要FilterInvocationSecurityMetadataSource了
這裡補充兩點:
1、這裡說的許可權和資源是一個意思
2、關於資源訪問控制,有兩種寫法。一種是基於許可權編碼的匹配,另一種是基於url的匹配。
- 第一種寫法是,基於許可權編碼。即在程式碼中定義好訪問某個資源需要什麼樣的許可權,這裡需要用到@PreAuthorize註解。
- 第二種寫法是,基於請求URL。即資料庫中配置好資源訪問的URL,根據請求URL是否與之匹配來判斷。(PS:可以比較許可權編碼,也可以比較許可權URL)
5. 退出登入
1 package com.example.demo.config;
2
3 import com.example.demo.handler.*;
4 import com.example.demo.service.MyAccessDecisionManager;
5 import com.example.demo.service.MyFilterInvocationSecurityMetadataSource;
6 import com.example.demo.service.MyUserDetailsService;
7 import org.springframework.beans.factory.annotation.Autowired;
8 import org.springframework.context.annotation.Bean;
9 import org.springframework.context.annotation.Configuration;
10 import org.springframework.security.config.annotation.ObjectPostProcessor;
11 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
12 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
13 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
14 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
15 import org.springframework.security.crypto.password.PasswordEncoder;
16 import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
17
18 @Configuration
19 public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
20
21 @Autowired
22 private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
23 @Autowired
24 private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
25 @Autowired
26 private MyAccessDeniedHandler myAccessDeniedHandler;
27 @Autowired
28 private MyLogoutSuccessHandler myLogoutSuccessHandler;
29 @Autowired
30 private MyUserDetailsService myUserDetailsService;
31 @Autowired
32 private MyFilterInvocationSecurityMetadataSource myFilterInvocationSecurityMetadataSource;
33 @Autowired
34 private MyAccessDecisionManager myAccessDecisionManager;
35
36 @Override
37 protected void configure(AuthenticationManagerBuilder auth) throws Exception {
38 auth.userDetailsService(myUserDetailsService)
39 .passwordEncoder(passwordEncoder());
40 }
41
42 @Override
43 protected void configure(HttpSecurity http) throws Exception {
44 http.formLogin()
45 .loginProcessingUrl("/login")
46 .usernameParameter("username")
47 .passwordParameter("password")
48 .defaultSuccessUrl("/")
49 .successHandler(myAuthenticationSuccessHandler)
50 .failureHandler(myAuthenticationFailureHandler)
51 .and().logout()
52 .logoutUrl("/logout")
53 .logoutSuccessHandler(myLogoutSuccessHandler)
54 .and()
55 .authorizeRequests()
56 .antMatchers("/login.html", "/login").permitAll()
57 .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
58 @Override
59 public <O extends FilterSecurityInterceptor> O postProcess(O object) {
60 object.setSecurityMetadataSource(myFilterInvocationSecurityMetadataSource);
61 object.setAccessDecisionManager(myAccessDecisionManager);
62 return object;
63 }
64 })
65 .and()
66 .exceptionHandling().accessDeniedHandler(myAccessDeniedHandler)
67 .and()
68 .sessionManagement().sessionFixation().migrateSession()
69 .maximumSessions(1).maxSessionsPreventsLogin(false).expiredSessionStrategy(new MyExpiredSessionStrategy());
70 }
71
72 @Bean
73 public PasswordEncoder passwordEncoder() {
74 return new BCryptPasswordEncoder();
75 }
76
77 }
自定義LogoutSuccessHandler
1 package com.example.demo.handler;
2
3 import com.fasterxml.jackson.databind.ObjectMapper;
4 import org.springframework.security.core.Authentication;
5 import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
6 import org.springframework.stereotype.Component;
7
8 import javax.servlet.ServletException;
9 import javax.servlet.http.HttpServletRequest;
10 import javax.servlet.http.HttpServletResponse;
11 import java.io.IOException;
12 import java.io.PrintWriter;
13
14 @Component
15 public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
16
17 private static ObjectMapper objectMapper = new ObjectMapper();
18
19 @Override
20 public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
21 // response.sendRedirect("/login.html");
22
23 response.setContentType("application/json;charset=utf-8");
24 PrintWriter printWriter = response.getWriter();
25 printWriter.write(objectMapper.writeValueAsString("logout success"));
26 printWriter.flush();
27 printWriter.close();
28 }
29 }
到這裡為止,我們已經實現了使用者動態載入,許可權匹配規則動態載入,即誰可以訪問什麼資源這個過程已經不再是寫死了,而是全部可配置化了
6. 整合JWT生成token
現在的專案都是前後端分離的,客戶端與服務端通過介面進行互動,資料格式採用JSON,這就要求服務端是無狀態的。如果還是利用Session在服務端維持會話的話,可擴充套件性就太差了。總之一句話,用Session就是有狀態的,用Token就是無狀態的,因此,我們要用Token來識別使用者身份。
預設會話是Session維持的,用Session的話不利於水平擴容(儘管共享Session,但還是很不方便),而且也沒法做前後端分離。因此,需要用token來承載認證使用者資訊,前後端通過json進行互動。
首先,引入依賴
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
然後,JWT工具類
1 package com.example.demo.util;
2
3 import io.jsonwebtoken.*;
4
5 import java.util.Date;
6 import java.util.HashMap;
7 import java.util.Map;
8 import java.util.function.Function;
9
10 /**
11 * @Author ChengJianSheng
12 * @Date 2021/5/7
13 */
14 public class JwtUtil {
15
16 private static long TOKEN_EXPIRATION = 24 * 60 * 60 * 1000;
17 private static String TOKEN_SECRET_KEY = "123456";
18
19 /**
20 * 生成Token
21 * @param subject 使用者名稱
22 * @return
23 */
24 public static String createToken(String subject) {
25 long currentTimeMillis = System.currentTimeMillis();
26 Date currentDate = new Date(currentTimeMillis);
27 Date expirationDate = new Date(currentTimeMillis + TOKEN_EXPIRATION);
28
29 // 存放自定義屬性,比如使用者擁有的許可權
30 Map<String, Object> claims = new HashMap<>();
31
32 return Jwts.builder()
33 .setClaims(claims)
34 .setSubject(subject)
35 .setIssuedAt(currentDate)
36 .setExpiration(expirationDate)
37 .signWith(SignatureAlgorithm.HS512, TOKEN_SECRET_KEY)
38 .compact();
39 }
40
41 public static String extractUsername(String token) {
42 return extractClaim(token, Claims::getSubject);
43 }
44
45 public static boolean isTokenExpired(String token) {
46 return extractExpiration(token).before(new Date());
47 }
48
49 public static Date extractExpiration(String token) {
50 return extractClaim(token, Claims::getExpiration);
51 }
52
53 public static <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
54 final Claims claims = extractAllClaims(token);
55 return claimsResolver.apply(claims);
56 }
57
58 private static Claims extractAllClaims(String token) {
59 return Jwts.parser().setSigningKey(TOKEN_SECRET_KEY).parseClaimsJws(token).getBody();
60 }
61
62 }
登入成功後,將token返回給客戶端
1 package com.example.demo.handler;
2
3 import com.example.demo.model.MyUserDetails;
4 import com.example.demo.util.JwtUtil;
5 import com.fasterxml.jackson.databind.ObjectMapper;
6 import org.springframework.security.core.Authentication;
7 import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
8 import org.springframework.stereotype.Component;
9
10 import javax.servlet.ServletException;
11 import javax.servlet.http.HttpServletRequest;
12 import javax.servlet.http.HttpServletResponse;
13 import java.io.IOException;
14
15 @Component
16 public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
17
18 private static ObjectMapper objectMapper = new ObjectMapper();
19
20 @Override
21 public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
22 MyUserDetails myUserDetails = (MyUserDetails) authentication.getPrincipal();
23 String username = myUserDetails.getUsername();
24 String token = JwtUtil.createToken(username);
25 //todo 快取到 Redis
26 //todo 把token存到Redis中
27
28 response.setContentType("application/json;charset=utf-8");
29 response.getWriter().write(objectMapper.writeValueAsString(token));
30 }
31 }
每次請求過來,從token中取到使用者資訊,然後放到上下文中
1 package com.example.demo.filter;
2
3 import com.example.demo.service.MyUserDetailsService;
4 import com.example.demo.util.JwtUtil;
5 import org.apache.commons.lang3.StringUtils;
6 import org.springframework.security.authentication.AuthenticationManager;
7 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
8 import org.springframework.security.core.context.SecurityContextHolder;
9 import org.springframework.security.core.userdetails.UserDetails;
10 import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
11 import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
12
13 import javax.servlet.FilterChain;
14 import javax.servlet.ServletException;
15 import javax.servlet.http.HttpServletRequest;
16 import javax.servlet.http.HttpServletResponse;
17 import java.io.IOException;
18
19 /**
20 * 負責在每次請求中,解析請求頭中的token,從中取得使用者資訊,生成認證物件傳遞給下一個過濾器
21 * @Author ChengJianSheng
22 * @Date 2021/5/7
23 */
24 public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
25
26 private MyUserDetailsService myUserDetailsService;
27
28 public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
29 super(authenticationManager);
30 }
31
32 public JwtAuthenticationFilter(AuthenticationManager authenticationManager, MyUserDetailsService myUserDetailsService) {
33 super(authenticationManager);
34 this.myUserDetailsService = myUserDetailsService;
35 }
36
37 @Override
38 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
39 String token = request.getHeader("token");
40 System.out.println("請求頭中帶的token: " + token);
41 if (StringUtils.isNoneBlank(token)) {
42 if (!JwtUtil.isTokenExpired(token)) {
43 String username = JwtUtil.extractUsername(token);
44 if (StringUtils.isNoneBlank(username) && null == SecurityContextHolder.getContext().getAuthentication()) {
45 // 查詢使用者許可權,有以下三種方式:
46 // 1. 可以從資料庫中載入
47 // 2. 可以從Redis中載入(PS: 前提是之前已經快取到Redis中了)
48 // 3. 可以從token中載入(PS: 前提是生成token的時候把使用者許可權作為Claims放置其中了)
49
50 UserDetails userDetails = myUserDetailsService.loadUserByUsername(username);
51
52 UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
53 authRequest.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
54
55 SecurityContextHolder.getContext().setAuthentication(authRequest);
56 }
57 }
58 }
59
60 chain.doFilter(request, response);
61 }
62 }
把這個過濾器新增到
1 http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager(), myUserDetailsService), UsernamePasswordAuthenticationFilter.class);
完整配置如下:
1 package com.example.demo.config;
2
3 import com.example.demo.filter.JwtAuthenticationFilter;
4 import com.example.demo.handler.*;
5 import com.example.demo.service.MyAccessDecisionManager;
6 import com.example.demo.service.MyFilterInvocationSecurityMetadataSource;
7 import com.example.demo.service.MyUserDetailsService;
8 import org.springframework.beans.factory.annotation.Autowired;
9 import org.springframework.context.annotation.Bean;
10 import org.springframework.context.annotation.Configuration;
11 import org.springframework.security.config.annotation.ObjectPostProcessor;
12 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
13 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
14 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
15 import org.springframework.security.config.http.SessionCreationPolicy;
16 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
17 import org.springframework.security.crypto.password.PasswordEncoder;
18 import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
19 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
20
21 @Configuration
22 public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
23
24 @Autowired
25 private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
26 @Autowired
27 private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
28 @Autowired
29 private MyAccessDeniedHandler myAccessDeniedHandler;
30 @Autowired
31 private MyLogoutSuccessHandler myLogoutSuccessHandler;
32 @Autowired
33 private MyUserDetailsService myUserDetailsService;
34 @Autowired
35 private MyFilterInvocationSecurityMetadataSource myFilterInvocationSecurityMetadataSource;
36 @Autowired
37 private MyAccessDecisionManager myAccessDecisionManager;
38 @Autowired
39 private MyAuthenticationEntryPoint myAuthenticationEntryPoint;
40
41 @Override
42 protected void configure(AuthenticationManagerBuilder auth) throws Exception {
43 auth.userDetailsService(myUserDetailsService)
44 .passwordEncoder(passwordEncoder());
45 }
46
47 @Override
48 protected void configure(HttpSecurity http) throws Exception {
49 http.formLogin()
50 .loginProcessingUrl("/login")
51 .usernameParameter("username")
52 .passwordParameter("password")
53 .successHandler(myAuthenticationSuccessHandler)
54 .failureHandler(myAuthenticationFailureHandler)
55 .and().logout()
56 .logoutUrl("/logout")
57 .logoutSuccessUrl("/login.html")
58 .logoutSuccessHandler(myLogoutSuccessHandler)
59 .and()
60 .authorizeRequests()
61 .antMatchers("/login.html", "/login").permitAll()
62 .anyRequest().access("@myAccessDecisionService.hasPermission(request, authentication)")
63 .and()
64 .exceptionHandling().accessDeniedHandler(myAccessDeniedHandler).authenticationEntryPoint(myAuthenticationEntryPoint)
65 .and()
66 .sessionManagement().sessionFixation().migrateSession().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
67 .maximumSessions(1).maxSessionsPreventsLogin(false).expiredSessionStrategy(new MyExpiredSessionStrategy());
68
69 http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager(), myUserDetailsService), UsernamePasswordAuthenticationFilter.class);
70
71 http.csrf().disable();
72 }
73
74 @Bean
75 public PasswordEncoder passwordEncoder() {
76 return new BCryptPasswordEncoder();
77 }
78
79 }
增加一個未登入的處理
1 package com.example.demo.handler;
2
3 import com.fasterxml.jackson.databind.ObjectMapper;
4 import org.springframework.security.core.AuthenticationException;
5 import org.springframework.security.web.AuthenticationEntryPoint;
6 import org.springframework.stereotype.Component;
7
8 import javax.servlet.ServletException;
9 import javax.servlet.http.HttpServletRequest;
10 import javax.servlet.http.HttpServletResponse;
11 import java.io.IOException;
12
13 /**
14 * 未認證(未登入)統一處理
15 * @Author ChengJianSheng
16 * @Date 2021/5/7
17 */
18 @Component
19 public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
20
21 private static ObjectMapper objectMapper = new ObjectMapper();
22
23 @Override
24 public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
25 response.setContentType("application/json;charset=utf-8");
26 response.getWriter().write(objectMapper.writeValueAsString("未登入,請先登入"));
27 }
28 }
改造後的專案結構如下
最後,用token以後,退出要做一點改動。由於我們採用JWT來生成Token,因此token是沒法撤銷和刪除的,所以此時的退出應該是:
- Token生成以後要儲存到資料庫(MySQL或者Redis)
- 每次請求要校驗Token是否存在及有效
- 退出登入後刪除資料庫中儲存的Token
關於Spring Security實現簡單的使用者、角色、許可權控制就先講到這裡,稍微做一個回顧:
- 未認證(登入)的使用者提示他要先登入
- 已認證的使用者判斷是否有許可權訪問