Spring Security 入门篇
阅读原文时间:2021年05月11日阅读:1

本文是一个笔记系列,目标是完成一个基于角色的权限访问控制系统(RBAC),有基本的用户、角色、权限管理,重点在Spring Security的各种配置。万丈高楼平地起,接下来,一步一步,由浅入深,希望给一起学习的小伙伴一个参考。

1.  Hello Security

按照惯例,先写个Hello World

首先,引入依赖

1 2 org.springframework.boot 3 spring-boot-starter-security 4

先来理清楚“认证”和“授权”两个概念。认证就是告诉我你是谁,授权就是你可以做什么。结合实际项目通俗地来讲,认证就是登录,授权就是访问资源。故而,我们需要先有用户和资源,先简单地定义几个内存用户和资源吧,为此需要在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 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 authorities) {
34 this.authorities = authorities;
35 }
36
37 @Override
38 public Collection 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 optionalSysUser = sysUserRepository.findByUsername(username);
37 SysUser sysUser = optionalSysUser.orElseThrow(()->new UsernameNotFoundException("用户名" + username + "不存在"));
38
39 // 查权限
40 List sysUserRoleRelationList = sysUserRoleRelationRepository.findByUserId(sysUser.getId());
41 List roleIds = sysUserRoleRelationList.stream().map(SysUserRoleRelation::getRoleId).collect(Collectors.toList());
42 List sysRoleList = sysRoleRepository.findByIdIn(roleIds);
43 List sysRolePermissionRelationList = sysRolePermissionRelationRepository.findByRoleIdIn(roleIds);
44 List permissionIds = sysRolePermissionRelationList.stream().map(SysRolePermissionRelation::getPermissionId).collect(Collectors.toList());
45 List sysPermissionList = sysPermissionRepository.findByIdIn(permissionIds);
46
47 List 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 {
9
10 List findByIdIn(List 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 getAttributes(Object object) throws IllegalArgumentException {
28 String requestUrl = ((FilterInvocation) object).getRequestUrl();
29
30 // 查找与当前请求URL匹配的所有权限
31 List sysPermissionList = sysPermissionRepository.findAll();
32 List 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 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 configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
30 String requestUrl = ((FilterInvocation) object).getRequestUrl();
31 System.out.println(requestUrl);
32
33 // 当前用户拥有的权限(能访问的资源)
34 Collection grantedAuthorities = authentication.getAuthorities();
35 List 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() {
56 @Override
57 public 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() {
58 @Override
59 public 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进行交互。

首先,引入依赖

io.jsonwebtoken jjwt 0.9.1

然后,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 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 extractClaim(String token, Function 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是没法撤销和删除的,所以此时的退出应该是:

  1. Token生成以后要保存到数据库(MySQL或者Redis)
  2. 每次请求要校验Token是否存在及有效
  3. 退出登录后删除数据库中保存的Token

关于Spring Security实现简单的用户、角色、权限控制就先讲到这里,稍微做一个回顾:

  1. 未认证(登录)的用户提示他要先登录
  2. 已认证的用户判断是否有权限访问