IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> Java知识库 -> Spring Security 总结 -> 正文阅读

[Java知识库]Spring Security 总结


title: Spring Security 总结
date: 2022-03-15 17:18:25
tags:

  • Spring
    categories:
  • Spring
    cover: https://cover.png
    feature: false

1. 概要

1.1 名词概念

1.1.1 主体(principal)

使用系统的用户或设备或从其他系统远程登录的用户等等。简单说就是谁使用系统谁就是主体。

1.1.2 认证(authentication)

权限管理系统确认一个主体的身份,允许主体进入系统。简单说就是“主体”证明自己是谁。笼统的认为就是以前所做的登录操作。

1.1.3 授权(authorization)

将操作系统的“权力”“授予”“主体”,这样主体就具备了操作系统中特定功能的能力。所以简单来说,授权就是给用户分配权限。

1.2 简介

关于安全方面的两个主要区域是“认证”和“授权”(或者访问控制),一般来说,Web 应用的安全性包括用户认证(Authentication)和用户授权(Authorization) 两个部分,这两点也是 Spring Security 重要核心功能。

  1. 用户认证: 验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程。通俗点说就是系统认为用户是否能登录
  2. 用户授权: 验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。通俗点讲就是系统判断用户是否有权限去做某些事情。

1.2.1 SpringSecurity 特点

  • 和 Spring 无缝整合
  • 全面的权限控制
  • 专门为 Web 开发而设计
    • 旧版本不能脱离 Web 环境使用
    • 新版本对整个框架进行了分层抽取,分成了核心模块和 Web 模块。单独引入核心模块就可以脱离 Web 环境
  • 重量级,shiro 是轻量级的

1.2.2 模块划分

在这里插入图片描述

2. 过滤器

Spring Security 采用的是责任链的设计模式,它有一条很长的过滤器链(15个),只有当前过滤器通过,才能进入下一个过滤器
在这里插入图片描述

org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
org.springframework.security.web.context.SecurityContextPersistenceFilter 
org.springframework.security.web.header.HeaderWriterFilter
org.springframework.security.web.csrf.CsrfFilter
org.springframework.security.web.authentication.logout.LogoutFilter 
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter 
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter 
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
org.springframework.security.web.authentication.www.BasicAuthenticationFilter
org.springframework.security.web.savedrequest.RequestCacheAwareFilter
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
org.springframework.security.web.authentication.AnonymousAuthenticationFilter 
org.springframework.security.web.session.SessionManagementFilter 
org.springframework.security.web.access.ExceptionTranslationFilter 
org.springframework.security.web.access.intercept.FilterSecurityInterceptor

2.1 WebAsyncManagerIntegrationFilter

将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成

2.2 SecurityContextPersistenceFilter

在请求开始时从配置好的 SecurityContextRepository(一个仓储) 中获取该请求相关的安全上下文信息 SecurityContext,然后加载到 SecurityContextHolder 中。然后在该次请求处理完成之后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好的 SecurityContextRepository(一个仓储),同时清除 SecurityContextHolder 所持有的 SecurityContext

2.3 HeaderWriterFilter

向请求的 Header 中添加相应的信息,将头信息加入响应中,可在 http 标签内部使用 security:headers 来控制

2.4 CsrfFilter

用于处理跨站请求伪造,Spring Security会对 PATCH,POST,PUT 和 DELETE 方法进行防护,验证请求是否包含系统生成的 csrf 的 Token 信息,如果不包含,则报错。

2.5 LogoutFilter

默认匹配 URL 为 /logout 的请求,实现用户注销,清除认证信息。

2.6 UsernamePasswordAuthenticationFilter(重要)

进行认证操作。用于处理基于表单的登录请求,默认会拦截前端提交的 URL 为 /login 且必须为 POST 方式的登录表单请求,并进行身份认证,校验表单中用户名,密码。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的 usernameParameter 和 passwordParameter 两个参数的值进行修改。该过滤器的 doFilter() 方法实现在其抽象父类 AbstractAuthenticationProcessingFilter 中。

2.7 DefaultLoginPageGeneratingFilter

如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录认证时生成一个登录表单页面。

2.8 DefaultLogoutPageGeneratingFilter

生成默认的注销页面

2.9 BasicAuthenticationFilter

此过滤器会自动解析 HTTP 请求中头部名字为 Authentication,且以 Basic 开头的头信息,检测和处理 HTTP Basic 认证

2.10 RequestCacheAwareFilter

通过 HttpSessionRequestCache 内部维护了一个 RequestCache,用于缓存 HttpServletRequest,处理请求的缓存

2.11 SecurityContextHolderAwareRequestFilter

针对 ServletRequest 进行了—次包装,使得 request 具有更加丰富的API。

2.12 AnonymousAuthenticationFilter

当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存入到SecurityContextHolder中。spring security为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。,检测 SecurityContextHolder 中是否存在Authentication 对象,如果不存在为其提供一个匿名 Authentication。

2.13 SessionManagementFilter

管理 session 的过滤器

2.14 ExceptionTranslationFilter

处理 AccessDeniedException 和 AuthenticationException 异常

2.15 FilterSecurityInterceptor

可以看做过滤器链的出口

  1. RememberMeAuthenticationFilter:当用户没有登录而直接访问资源时, 从 cookie 里找出用户的信息, 如果 Spring Security 能够识别出用户提供的 remember me cookie, 用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。

2.2 核心组件

2.2.1 Authentication 接口

Spring Security 的认证主体,在Spring Security 中 Authentication 用来表示当前用户是谁,可以看作 authentication 就是一组用户名密码信息。接口定义如下:

public interface Authentication extends Principal, Serializable {

	// 获取用户权限集合
	Collection<? extends GrantedAuthority> getAuthorities();

	// 获取用户认证信息,通常是密码等信息
	Object getCredentials();

	// 存储有关身份验证请求的其他详细信息。如 IP 地址、证书序列号等
	Object getDetails();

	// 获取用户的身份信息,未认证时获取到的是前端请求传入的用户名
	// 认证成功后为封装用户信息的 UserDetails 对象
	Object getPrincipal();
	
	// 获取当前 Authentication 是否已认证
	boolean isAuthenticated();
	
	// 设置当前 Authentication 是否已认证
	void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

获取当前登录用户信息

// 已登录,获取用户信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

String username = authentication.getName(); // 获取登录的用户名
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); // 用户的所有权限
// 获取封装用户信息的 UserDetails 对象
User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

2.2.2 SecurityContext 接口

安全上下文,用户通过 Spring Security 的校验之后,验证信息存储在 SecurityContext 中,接口定义如下:

public interface SecurityContext extends Serializable {
    // 获取当前已认证的主体或者认证请求令牌
    Authentication getAuthentication();

	// 更改当前已认证的主题、或删除认证信息
    void setAuthentication(Authentication var1);
}

这里只定义了两个方法,主要都是用来获取或修改认证信息(Authentication),Authentication 是用来存储着认证用户的信息,所以这个接口可以间接获取到用户的认证信息。

SecurityContext securityContext = SecurityContextHolder.getContext();
Authentication authentication = securityContext.getAuthentication();
User user = (User) authentication.getPrincipal();

2.2.3 SecurityContextHolder

在典型的 Web 应用程序中,用户登录一次,然后由其会话ID标识。服务器缓存持续时间会话的主体信息。但是在Spring Security中,在请求之间存储 SecurityContext 的责任落在 SecurityContextPersistenceFilter 上,默认情况下,该上下文将上下文存储为HTTP请求之间的 HttpSession 属性。它会为每个请求恢复上下文 SecurityContextHolder,并且最重要的是,在请求完成时清除 SecurityContextHolder。
在这里插入图片描述

2.2.4 AuthenticationManager

校验 Authentication,如果验证失败会抛出 AuthenticationException 异常。AuthenticationException 是一个抽象类,因此代码逻辑并不能实例化一个 AuthenticationException 异常并抛出,实际上抛出的异常通常是其实现类,如 DisabledException、LockedException、BadCredentialsException等。接口定义如下,其中可以包含多个 AuthenticationProvider:

public interface AuthenticationManager {
    Authentication authenticate(Authentication var1) throws AuthenticationException;
}

2.2.5 UserDetailsService 接口

当什么也没有配置的时候,账号和密码是由 Spring Security 定义生成的。而在实际项目中账号和密码都是从数据库中查询出来的。 所以我们要通过自定义逻辑控制认证逻辑。要自定义逻辑,需要自定义一个实现类实现 UserDetailsService 接口,让 Spring Security 使用我们的 UserDetailsService 。我们自己的 UserDetailsService 可以从数据库中查询用户名和密码。

package org.springframework.security.core.userdetails;

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

2.2.5.1 UserDetails

返回值 UserDetails,这个类是系统默认的用户“主体”

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
	return new UserDetails() {
		@Override
		// 表示获取登录用户所有权限
		public Collection<? extends GrantedAuthority> getAuthorities() {
			return null;
		}
		@Override
		// 表示获取密码
		public String getPassword() {
			return null;
		}
		@Override
		// 表示获取用户名
		public String getUsername() {
			return null;
		}
		@Override
		// 表示判断账户是否过期
		public boolean isAccountNonExpired() {
			return false;
		}
		@Override
		// 表示判断账户是否被锁定
		public boolean isAccountNonLocked() {
			return false;
		}
		@Override
		// 表示凭证{密码}是否过期
		public boolean isCredentialsNonExpired() {
			return false;
		}
		@Override
		// 表示当前用户是否可用
		public boolean isEnabled() {
			return false;
		}
	};
}

2.2.5.2 User

UserDetails 实现类:
在这里插入图片描述
可以使用 User 这个实现类返回用户名、密码和权限。
在这里插入图片描述
方法参数 username,表示用户名。此值是客户端表单传递过来的数据。默认情况下必须叫 username,否则无法接收。

2.2.6 PasswordEncoder 接口

package org.springframework.security.crypto.password;

public interface PasswordEncoder {
	// 表示把参数按照特定的解析规则进行解析
    String encode(CharSequence rawPassword);

	/* 表示验证从存储中获取的编码密码与编码后提交的原始密码是否匹配。如果密码匹配,则返回 true;
	如果不匹配,则返回 false。第一个参数表示需要被解析的密码。第二个参数表示存储的密码。 */
    boolean matches(CharSequence rawPassword, String encodedPassword);

	// 表示如果解析的密码能够再次进行解析且达到更安全的结果则返回 true,否则返回false。默认返回 false。
    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

接口实现类:
在这里插入图片描述

  • BCryptPasswordEncoder 是 Spring Security 官方推荐的密码解析器,平时常使用这个解析器。
  • BCryptPasswordEncoder 是对 bcrypt 强散列方法的具体实现。是基于 Hash 算法实现的单向加密。可以通过 strength 控制加密强度,默认 10。
// 使用
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
// 对密码进行加密
String fan = bCryptPasswordEncoder.encode("fan");
// 打印加密之后的数据
System.out.println("加密之后数据:\t" + fan);
//判断原字符加密后和加密之前是否匹配
boolean result = bCryptPasswordEncoder.matches("fan", fan);
// 打印比较结果
System.out.println("比较结果:\t"+result);

在这里插入图片描述

  • ProviderManager 对象为 AuthenticationManager 接口的实现类
  • AuthenticationProvider:要用来进行认证操作的类,调用其中的 authenticate() 方法去进行认证操作
  • GrantedAuthority:对认证主题的应用层面的授权,含当前用户的权限信息,通常使用角色表示
  • UserDetails:构建 Authentication 对象必须的信息,可以自定义,可能需要访问DB得到
  • UserDetailsService:通过 username 构建 UserDetails 对象,通过 loadUserByUsername() 方法根据username 获取 UserDetail 对象

2.3 工作流程

2.3.1 登录校验流程

在这里插入图片描述
在这里插入图片描述

2.3.3 授权流程

2.4 重要过滤器(认证流程和授权流程)

2.4.2 UsernamePasswordAuthenticationFilter(认证流程)

该过滤器会拦截前端提交的 POST 方式的登录表单请求,并进行身份认证,校验表单中用户名,密码。该过滤器的 doFilter() 方法实现在其抽象父类 AbstractAuthenticationProcessingFilter 中:

// 过滤器 doFilter() 方法
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {

	HttpServletRequest request = (HttpServletRequest) req;
	HttpServletResponse response = (HttpServletResponse) res;

	// 1. 判断该请求是否为 POST 方式的登录表单提交请求,如果不是则直接放行,进入下一个过滤器
	if (!requiresAuthentication(request, response)) {
		chain.doFilter(request, response);
		return;
	}

	if (logger.isDebugEnabled()) {
		logger.debug("Request is to process authentication");
	}
	
	// Authentication 是用来存储用户认证信息的类
	Authentication authResult;

	try {
		// 2. 调用子类 UsernamePasswordAuthenticationFilter 重写的方法进行身份认证
		// 返回的 authResult 对象封装认证后的用户信息
		authResult = attemptAuthentication(request, response);
		if (authResult == null) {
			// return immediately as subclass has indicated that it hasn't completed
			// authentication
			return;
		}
		// 3. Session 策略处理(如果配置了用户 Session 最大并发数,就是在此处进行判断并处理)
		sessionStrategy.onAuthentication(authResult, request, response);
	}
	catch (InternalAuthenticationServiceException failed) {
		logger.error(
				"An internal error occurred while trying to authenticate the user.",
				failed);
		unsuccessfulAuthentication(request, response, failed);

		return;
	}
	catch (AuthenticationException failed) {
		// Authentication failed,4. 认证失败,调用认证失败的处理器
		unsuccessfulAuthentication(request, response, failed);

		return;
	}

	// Authentication success,4. 认证成功的处理
	if (continueChainBeforeSuccessfulAuthentication) {
		// 默认的 continueChainBeforeSuccessfulAuthentication 为 false,所以认证成功后不进入下一个过滤器
		chain.doFilter(request, response);
	}
	
	// 调用认证成功的处理器
	successfulAuthentication(request, response, chain, authResult);
}

上面第二步调用了 UsernamePasswordAuthenticationFilter 的 attemptAuthentication() 方法:

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
	
	public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
	public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

	//  默认表单用户名参数为 username
	private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
	//  默认表单密码参数为 password
	private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
	// 默认请求方式只能为 POST
	private boolean postOnly = true;

	public UsernamePasswordAuthenticationFilter() {
		// 默认登录表单提交路径为 /login,POST 方式请求
		super(new AntPathRequestMatcher("/login", "POST"));
	}
	
	// 上面的 doFilter() 方法调用此 attemptAuthentication() 进行身份认证
	public Authentication attemptAuthentication(HttpServletRequest request,
			HttpServletResponse response) throws AuthenticationException {
		if (postOnly && !request.getMethod().equals("POST")) {
			// 1. 默认情况下,如果请求方式不是 POST,会抛出异常
			throw new AuthenticationServiceException(
					"Authentication method not supported: " + request.getMethod());
		}

		// 2. 获取请求携带的 username 和 password
		String username = obtainUsername(request);
		String password = obtainPassword(request);

		if (username == null) {
			username = "";
		}

		if (password == null) {
			password = "";
		}

		username = username.trim();

		// 3. 使用前端传入的 username、password 构造 Authentication 对象,标记该对象未认证
		UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
				username, password);

		// Allow subclasses to set the "details" property
		// 4. 将请求中的一些属性信息设置到 Authentication   对象中,如:remoteAddress、sessionId
		setDetails(request, authRequest);

		// 5. 调用 ProviderManager 类的 authenticate() 方法进行身份认证
		return this.getAuthenticationManager().authenticate(authRequest);
	}
}

上面第三步创建的 UsernamePasswordAuthenticationToken 是 Authentication 接口的实现类,该类有两个构造器,一个用于封装前端请求传入的未认证的用户信息,一个用于封装认证成功后的用户信息:

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
	private final Object principal;
	private Object credentials;

	// 用于封装前端请求传入的未认证的用户信息,前面的 authRequest 对象就是调用该构造器进行构造的
	public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
		super(null); // 用户权限为 null
		this.principal = principal; // 前端传入的用户名
		this.credentials = credentials; // 前端传入的密码
		setAuthenticated(false); // 标记未认证
	}
	
	// 用户封装认证成功后的用户信息
	public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
			Collection<? extends GrantedAuthority> authorities) {
		super(authorities); // 用户权限集合
		this.principal = principal; // 封装认证用户信息的 UserDetails 对象,不再是用户名
		this.credentials = credentials; // 前端传入的密码
		super.setAuthenticated(true); // must use super, as we override,标记认证成功
	}

	// ~ Methods
}

上面 UsernamePasswordAuthenticationFilter 过滤器的attemptAuthentication() 方法的第五步将未认证的 Authentication 对象传入 ProviderManager 类的 authenticate() 方法进行身份认证。

ProviderManager 是 AuthenticationManager 接口的实现类,该接口是认证相关的核心接口,也是认证的入口。在实际开发中,我们可能有多种不同的认证方式,例如:用户名+密码、邮箱+密码、手机号+验证码等,而这些认证方式的入口始终只有一个,那就是 AuthenticationManager。在该接口的常用实现类 ProviderManager 内部会维护一个 List<AuthenticationProvider> 列表,存放多种认证方式,实际上这是委托者模式(Delegate)的应用。每种认证方式对应着一个 AuthenticationProvider,AuthenticationManager 根据认证方式的不同(根据传入的 Authentication 类型判断)委托对应的 AuthenticationProvider 进行用户认证。

public class ProviderManager implements AuthenticationManager, MessageSourceAware,
		InitializingBean {
	// ~ Static fields/initializers
	// ~ Instance fields

	// 传入未认证的 Authentication 对象
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		// 1. 获取传入的 Authentication 类型,即 UsernamePasswordAuthenticationToken.class
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		AuthenticationException parentException = null;
		Authentication result = null;
		Authentication parentResult = null;
		boolean debug = logger.isDebugEnabled();
		
		// 2. 循环遍历认证方式列表 
		for (AuthenticationProvider provider : getProviders()) {
			// 3. 判断当前 AuthenticationProvider 是否适用 UsernamePasswordAuthenticationToken.class 类型的 Authentication
			if (!provider.supports(toTest)) {
				continue;
			}

			if (debug) {
				logger.debug("Authentication attempt using "
						+ provider.getClass().getName());
			}
			
			// 成功找到适配当前认证方式的 AuthenticationProvider ,此处为 DaoAuthenticationProvider
			try {
				// 4. 调用 DaoAuthenticationProvider 的 authenticate() 方法进行认证
				// 如果认证成功,会返回一个标记已认证的 Authentication 对象
				result = provider.authenticate(authentication);

				if (result != null) {
					// 5. 认证成功后,将传入的 Authentication 对象中的 details 信息拷贝到已认证的 Authentication 对象中
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException | InternalAuthenticationServiceException e) {
				prepareException(e, authentication);
				// SEC-546: Avoid polling additional providers if auth failure is due to
				// invalid account status
				throw e;
			} catch (AuthenticationException e) {
				lastException = e;
			}
		}

		if (result == null && parent != null) {
			// Allow the parent to try.
			try {
				// 5. 认证失败,使用父类型 AuthenticationManager 进行验证
				result = parentResult = parent.authenticate(authentication);
			}
			catch (ProviderNotFoundException e) {
				// ignore as we will throw below if no other exception occurred prior to
				// calling parent and the parent
				// may throw ProviderNotFound even though a provider in the child already
				// handled the request
			}
			catch (AuthenticationException e) {
				lastException = parentException = e;
			}
		}

		if (result != null) {
			// 6. 认证成功之后,去除 result 中的敏感信息,要求相关类实现 CredentialsContainer 接口
			if (eraseCredentialsAfterAuthentication
					&& (result instanceof CredentialsContainer)) {
				// Authentication is complete. Remove credentials and other secret data
				// from authentication
				// 去除过程就是调用 CredentialsContainer 接口的 eraseCredentials() 方法
				((CredentialsContainer) result).eraseCredentials();
			}

			// If the parent AuthenticationManager was attempted and successful than it will publish an AuthenticationSuccessEvent
			// This check prevents a duplicate AuthenticationSuccessEvent if the parent AuthenticationManager already published it
			// 7. 发布认证成功的事件
			if (parentResult == null) {
				eventPublisher.publishAuthenticationSuccess(result);
			}
			return result;
		}

		// Parent was null, or didn't authenticate (or throw an exception).
		// 8. 认证失败之后,抛出失败的异常信息
		if (lastException == null) {
			lastException = new ProviderNotFoundException(messages.getMessage(
					"ProviderManager.providerNotFound",
					new Object[] { toTest.getName() },
					"No AuthenticationProvider found for {0}"));
		}

		// If the parent AuthenticationManager was attempted and failed than it will publish an AbstractAuthenticationFailureEvent
		// This check prevents a duplicate AbstractAuthenticationFailureEvent if the parent AuthenticationManager already published it
		if (parentException == null) {
			prepareException(lastException, authentication);
		}

		throw lastException;
	}
	// ~ Methods
}

上面认证成功之后的第六步,调用 CredentialsContainer 接口定义的 eraseCredentials() 方法去除敏感信息。查看 UsernamePasswordAuthenticationToken 实现的 eraseCredentials() 方法,该方法实现在其父类中:

public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer {
	// 父类实现了 CredentialsContainer 接口
	// ~ Instance fields
	// ~ Constructors

	public void eraseCredentials() {
		// credentials(前端传入的密码)会置为 null
		eraseSecret(getCredentials());
		// principal 在已认证的 Authentication 中是 UserDetails 的实现类,如果该实现类想要去除敏感信息,需要实现
		// CredentialsContainer 的 eraseCredentials() 方法,由于自定义的 User 类没有实现该接口,所以不进行任何操作
		eraseSecret(getPrincipal());
		eraseSecret(details);
	}

	private void eraseSecret(Object secret) {
		if (secret instanceof CredentialsContainer) {
			((CredentialsContainer) secret).eraseCredentials();
		}
	}

	// ~ Methods
}

上述过程就是认证流程的最核心部分,接下来重新回到 UsernamePasswordAuthenticationFilter 过滤器的 doFilter() 方法,查看认证成功/失败的处理:

// 认证成功后的处理
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {

	if (logger.isDebugEnabled()) {
		logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
	}
	
	// 1. 将认证成功的用户信息对象 Authentication 封装进 SecurityContext 对象中,并存入 SecurityContextHolder
	// SecurityContextHolder 是对 ThreadLocal 的一个封装
	SecurityContextHolder.getContext().setAuthentication(authResult);

	// 2. rememberMe 的处理
	rememberMeServices.loginSuccess(request, response, authResult);

	// Fire event
	if (this.eventPublisher != null) {
		// 3. 发布认证成功的事件
		eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
	}
	
	// 4. 调用认证成功处理器
	successHandler.onAuthenticationSuccess(request, response, authResult);
}
// 认证失败后的处理
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
	// 1. 清除该线程在 SecurityContextHolder 中对应的 SecurityContext 对象
	SecurityContextHolder.clearContext();

	if (logger.isDebugEnabled()) {
		logger.debug("Authentication request failed: " + failed.toString(), failed);
		logger.debug("Updated SecurityContextHolder to contain null Authentication");
		logger.debug("Delegating to authentication failure handler " + failureHandler);
	}

	// 2. rememberMe 的处理
	rememberMeServices.loginFail(request, response);

	// 3. 调用认证成功处理器
	failureHandler.onAuthenticationFailure(request, response, failed);
}

在这里插入图片描述

2.4.3 ExceptionTranslationFilter(授权)

是个异常过滤器,该过滤器不需要我们配置,对于前端提交的请求会直接放行,捕获后续抛出的异常并进行处理(例如:权限访问限制)。

public class ExceptionTranslationFilter extends GenericFilterBean {

	// ~ Instance fields
	// ~ Methods

	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		try {
			// 1. 对应前端提交的请求会直接放行,不进行拦截
			chain.doFilter(request, response);

			logger.debug("Chain processed normally");
		}
		catch (IOException ex) {
			throw ex;
		}
		catch (Exception ex) {
			// Try to extract a SpringSecurityException from the stacktrace
			// 2. 捕获后续出现的异常进行处理
			Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
			// 3. 访问需要认证的资源,但当前请求未认证所抛出的异常
			RuntimeException ase = (AuthenticationException) throwableAnalyzer
					.getFirstThrowableOfType(AuthenticationException.class, causeChain);

			if (ase == null) {
				// 访问权限受限的资源所抛出的异常
				ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
			}

			if (ase != null) {
				if (response.isCommitted()) {
					throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
				}
				handleSpringSecurityException(request, response, chain, ase);
			}
			else {
				// Rethrow ServletExceptions and RuntimeExceptions as-is
				if (ex instanceof ServletException) {
					throw (ServletException) ex;
				}
				else if (ex instanceof RuntimeException) {
					throw (RuntimeException) ex;
				}

				// Wrap other Exceptions. This shouldn't actually happen
				// as we've already covered all the possibilities for doFilter
				throw new RuntimeException(ex);
			}
		}
	}
	
	// ~ Methods
}

2.4.4 FilterSecurityInterceptor(授权)

该过滤器是过滤器链的最后一个过滤器,前面解决了认证问题,接下来是是否可访问指定资源的问题,FilterSecurityInterceptor 用了 AccessDecisionManager 来进行鉴权。根据资源权限配置来判断当前请求是否有权限访问对应的资源。如果访问受限会抛出相关异常,最终所抛出的异常会由前一个过滤器 ExceptionTranslationFilter 过滤器进行捕获和处理。

public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
	// ~ Static fields/initializers
	// ~ Instance fields
	// ~ Methods

	// 过滤器的 doFilter() 方法
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
		FilterInvocation fi = new FilterInvocation(request, response, chain);
		// 调用 invoke() 方法
		invoke(fi);
	}

	public void invoke(FilterInvocation fi) throws IOException, ServletException {
		if ((fi.getRequest() != null)
				&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
				&& observeOncePerRequest) {
			// filter already applied to this request and user wants us to observe
			// once-per-request handling, so don't re-do security checking
			fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
		}
		else {
			// first time this request being called, so perform security checking
			if (fi.getRequest() != null && observeOncePerRequest) {
				fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
			}

			// 1. 根据资源权限配置来判断当前请求是否有权限访问对应的资源,如果不能访问,则抛出响应的异常
			InterceptorStatusToken token = super.beforeInvocation(fi);

			try {
				// 2. 访问相关资源,通过 SpringMVC 的核心组件 DispatcherServlet 进行访问
				fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
			}
			finally {
				super.finallyInvocation(token);
			}

			super.afterInvocation(token, null);
		}
	}
}

Spring Security 的过滤器链是配置在 SpringMVC 的核心组件 DispatcherServlet 运行之前。也就是说,请求通过 Spring Security 的所有过滤器,不意味着能够正常访问资源,该请求还需要通过 SpringMVC 的拦截器链。

2.7 请求间共享认证信息

在这里插入图片描述

  • 查看 SecurityContext 接口及其实现类 SecurityContextImpl , 该类其实就是 对Authentication 的封装
  • 查看 SecurityContextHolder 类 , 该类其实是对 ThreadLocal 的封装 , 存储 SecurityContext 对象

SecurityContextPersistenceFilter 过滤器

在 UsernamePasswordAuthenticationFilter 过滤器认证成功之后,会在认证成功的处理方法中将已认证的用户信息对象 Authentication 封装进 SecurityContext,并存入 SecurityContextHolder。之后,响应会通过 SecurityContextPersistenceFilter 过滤器,该过滤器的位置在所有过滤器的最前面,请求到来先进它,响应返回最后一个通过它,所以在该过滤器中处理已认证的用户信息对象 Authentication 与 Session 绑定。

认证成功的响应通过 SecurityContextPersistenceFilter 过滤器时,会从 SecurityContextHolder 中取出封装了已认证用户信息对象 Authentication 的 SecurityContext,放进 Session 中。当请求再次到来时,请求首先经过该过滤器,该过滤器会判断当前请求的 Session 是否存有 SecurityContext 对象,如果有则将该对象取出再次放入 SecurityContextHolder 中,之后该请求所在的线程获得认证用户信息,后续的资源访问不需要进行身份认证;当响应再次返回时,该过滤器同样从 SecurityContextHolder 取出 SecurityContext 对象,放入 Session 中。

3. SpringSecurity Web 权限

Spring Security 的核心配置类是 WebSecurityConfigurerAdapter 抽象类,这是权限管理启动的入口。

3.1 设置登录系统的账号、密码

3.1.1 YML

spring:
  security:
    user:
      name: fan
      password: fan

3.1.2 配置基于内存的角色授权和认证信息

继承 WebSecurityConfigurerAdapter,重写 configure(AuthenticationManagerBuilder auth) 方法

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        // 对密码进行加密
        String encode = bCryptPasswordEncoder.encode("fan223");
        // 设置用户名、加密后的密码、权限
        auth.inMemoryAuthentication().withUser("fan223").password(encode).roles("admin");
    }

	// 需要注入一个 PasswordEncoder 的 Bean,不然会报错,找不到 PasswordEncoder
    @Bean
    PasswordEncoder passwordEncoder(){
        // return new BCryptPasswordEncoder();
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence charSequence) {
            	// 加密
                return charSequence.toString();
            }

            @Override
            public boolean matches(CharSequence charSequence, String s) {
            	// 比对
                return Objects.equals(charSequence.toString(), s);
            }
        };
    }
}

3.1.3 配置基于数据库的认证信息和角色授权

1、编写实现类,实现 UserDetailsService 接口,实现其 UserDetails loadUserByUsername(String username) 方法,返回一个 UserDetails 接口的实现类 User 对象,包括用户名、密码、权限。
2、配置类里将实现类注入进入

// 配置类,注入 UserDetailsService
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Resource
    private UserDetailsService myUserDetailsServiceImpl;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailsServiceImpl).passwordEncoder(passwordEncoder());
    }

	// 注入 PasswordEncoder 类到 spring 容器中,用来对密码进行加密
    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}
// 实现类
@Service
public class MyUserDetailsServiceImpl implements UserDetailsService {
    @Resource
    private UserDAO userDAO;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 通过用户名从数据库查询用户信息
        fan.springsecuritytest.entity.User selectOne = userDAO.selectOne(new QueryWrapper<fan.springsecuritytest.entity.User>().eq("username", username));
        if (ObjectUtils.isEmpty(selectOne)){
            throw new UsernameNotFoundException("用户名不存在!");
        }
        // 权限列表,应从数据库中查
        List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("role, ROLE_sale");
        // 给用户设置权限和角色
        return new User(selectOne.getUsername(), new BCryptPasswordEncoder().encode(selectOne.getPassword()), authorities);
    }
}

3.2 自定义表单认证登录(重要)

3.2.1 配置类

// 配置类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Resource
    private UserDetailsService myUserDetailsServiceImpl;
	
	@Resource
    private AuthenticationSuccessHandler loginSuccessHandler; // 认证成功结果处理器
    // 或 private LoginSuccessHandler loginSuccessHandler;
    @Resource
    private AuthenticationFailureHandler loginFailureHandler; // 认证失败结果处理器
    // 或 private LoginFailureHandler loginFailureHandler;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailsServiceImpl).passwordEncoder(passwordEncoder());
    }

	// 注入 PasswordEncoder 类到 spring 容器中,用来对密码进行加密
    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable() // 关闭跨站 csrf 攻击防护
            // 1.配置权限认证
            .authorizeRequests()
                // 不需要通过登录验证就可以被访问到的资源路径
                .antMatchers("/", "/hello", "/login").permitAll()
                // 前面是资源的访问路径,后面是资源的名称或资源 ID,需要 admin 权限才能访问该路径
                .antMatchers("/web/admin/**").hasAnyAuthority("admin")
                .anyRequest() // 任何其他请求
                .authenticated(); // 都需要认证
                .and()
            // 2. 配置登录表单认证方式
            .formLogin()
                /* 用户未登录时,访问任何资源都跳转到该路径,即登录页面,需要将这个地址设置为不认证也可以访问。如果不这样设置,
                页面会提示“重定向次数过多”。因为登录的时候会访问 "login" 路径,设置新的登录地址后,一直来访问新的这个地址,
                但是这个地址必须登录才可以访问,所以一直循环这样调用,就会出现重定向次数过多。需要在 Controller 中映射。 */
                .loginPage("/login")
                // 登录表单 form 中 action 的地址,也就是处理认证请求的路径,这个路径也需要放开,但不需要在 Controller 中映射
                .loginProcessingUrl("/user/login")
                // 登录表单 form 中用户名输入框 input 的 name 名, 不改的话默认是 username
                .usernameParameter("uname")
                // form 中密码输入框 input 的 name 名,不改默认是 password
                .passwordParameter("pword")
                // .defaultSuccessUrl("/success") //登录认证成功后默认转跳的路径,与 successForwardUrl同效果
                // 登录成功跳转路径,假如不是直接访问 /login,而是其他请求被拦截跳转到 /login,则登录成功会转发到拦截的请求路径,不会跳转到该路径
                .successForwardUrl("/success")
                .failureForwardUrl("/error") // 登录失败跳转路径,假如不设置就默认跳转到 /login
                 // 使用自定义的登录成功结果处理器
                .successHandler(loginSuccessHandler)
                // 使用自定义登录失败的结果处理器
                .failureHandler(loginFailureHandler)
                .and()
            // 3. 注销
            .logout()
                .logoutUrl("/logout") // 配置注销登录请求URL为 "/logout"(默认也就是 /logout)
                // 使用自定义的注销成功结果处理器(优先级高)
                .logoutSuccessHandler(new CustomLogoutSuccessHandler())
                // 退出成功后跳转的路径
                .logoutSuccessUrl("/login")
                .clearAuthentication(true) // 清除身份认证信息
                .invalidateHttpSession(true) //使Http会话无效
                .permitAll() // 允许访问登录表单、登录接口
                .and()
            // 4. session管理
            .sessionManagement()
                .invalidSessionUrl("/login") //失效后跳转到登陆页面
                //单用户登录,如果有一个登录了,同一个用户在其他地方登录将前一个剔除下线
                //.maximumSessions(1).expiredSessionStrategy(expiredSessionStrategy())
                //单用户登录,如果有一个登录了,同一个用户在其他地方不能登录
                //.maximumSessions(1).maxSessionsPreventsLogin(true);
		http.headers().frameOptions().disable(); // 开启运行iframe嵌套页面
    }
    
	@Override
	public void configure(WebSecurity web) throws Exception {
		// 配置静态文件不需要认证
        web.ignoring().antMatchers("/static/**");
	}
}

3.2.2 配置静态资源

仅仅通过 Spring Security 配置是不够的,还需要去重写 addResourceHandlers 方法去映射静态资源。写一个类 WebMvcConfig 继承 WebMvcConfigurationSupport。

@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**")
                .addResourceLocations("classpath:/static/");
        super.addResourceHandlers(registry);
    }
}

3.2.3 配置错误页面(非必要)

可以不进行配置,只需对应错误页面放在 /error 文件夹下即可
在这里插入图片描述

@Configuration
public class ErrorPageConfig {

    @Bean
    public WebServerFactoryCustomizer<ConfigurableWebServerFactory> webServerFactoryCustomizer() {
        WebServerFactoryCustomizer<ConfigurableWebServerFactory> webServerFactoryCustomizer = new WebServerFactoryCustomizer<ConfigurableWebServerFactory>() {
            @Override
            public void customize(ConfigurableWebServerFactory factory) {
                ErrorPage[] errorPages = new ErrorPage[] {
                        new ErrorPage(HttpStatus.FORBIDDEN, "/403"),
                        new ErrorPage(HttpStatus.NOT_FOUND, "/404"),
                        new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/500"),
                };
                factory.addErrorPages(errorPages);
            }
        };
        return webServerFactoryCustomizer;
    }
}

3.2.4 Controller

// Controller
@Controller
public class TestController {
    @GetMapping("/login") // 登录页面映射 .loginPage("/login")
    public String login(){
        return "login";
    }

    @GetMapping("/error") // 错误页面映射 .failureForwardUrl("/error") // 登录失败跳转路径
    public String error(){
        return "error";
    }

    @GetMapping("/success") // 成功页面映射 .successForwardUrl("/success") // 登录成功跳转路径
    public String success(){
        return "success";
    }

    @GetMapping("/hello") // 不需要认证即可访问页面映射
    public String hello(){
        return "/hello";
    }

    @GetMapping("/test") // 需要认证才可访问,访问该路径会自动跳转到 /login,登录成功后才会转发到该路径
    @ResponseBody
    public String test(){
        return "test 请求";
    }
}

3.2.5 自定义权限决策管理器

@Component
public class CustomAccessDecisionManager implements AccessDecisionManager {

    /* 取当前用户的权限与这次请求的这个url需要的权限作对比,决定是否放行
     * auth 包含了当前的用户信息,包括拥有的权限,即之前UserDetailsService登录时候存储的用户对象
     * object 就是FilterInvocation对象,可以得到request等web资源。
     * configAttributes 是本次访问需要的权限。即上一步的 MyFilterInvocationSecurityMetadataSource 中查询核对得到的权限列表 */
    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
        for (ConfigAttribute configAttribute : configAttributes) {
            if (authentication == null){
                throw new AccessDeniedException("当前访问没有权限");
            }
            // 当前请求需要的权限
            String needRole = configAttribute.getAttribute();
            if ("ROLE_LOGIN".equals(needRole)){
                if (authentication instanceof AnonymousAuthenticationToken){
                    throw new  BadCredentialsException("未登录");
                }
                return;
            }
            // 当前用户所具有的权限
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equals(needRole)){
                    return;
                }
            }
        }
        throw new AccessDeniedException("权限不足");
    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return true;
    }
}

3.2.6 登录认证成功处理器

在 UsernamePasswordAuthenticationFilter 进行登录认证的时候,如果登录成功了会调用 AuthenticationSuccessHandler 的方法进行认证成功后的处理。AuthenticationSuccessHandler 就是登录成功处理器。
在这里插入图片描述

@Component
// 继承实现类 public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
// 实现接口
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
    @Resource
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        // 获取前端传到后端的全部参数
        Enumeration<String> parameterNames = request.getParameterNames();
        while (parameterNames.hasMoreElements()) {
            String paraName = parameterNames.nextElement();
            System.out.println("参数- " + paraName + " : " + request.getParameter(paraName));
        }
        // 这里写登录成功后的逻辑,可以验证其他信息,如验证码等。
//        response.setContentType("application/json;charset=UTF-8");
//        JSONObject jsonObject = new JSONObject();
//        jsonObject.putOnce("code", HttpStatus.OK.value());
//        jsonObject.putOnce("msg","登录成功");
//        jsonObject.putOnce("authentication",objectMapper.writeValueAsString(authentication));
		// 返回响应信息
//		response.getWriter().write(jsonObject.toString());
        try {
        	// 重定向,等同于 .successForwardUrl("/success")
            this.getRedirectStrategy().sendRedirect(request, response, "/success");
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这里插入图片描述

3.2.7 登录认证失败处理器

在 UsernamePasswordAuthenticationFilter 进行登录认证的时候,如果认证失败了会调用 AuthenticationFailureHandler 的方法进行认证失败后的处理。AuthenticationFailureHandler 就是登录失败处理器。
在这里插入图片描述

@Component
// public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
public class LoginFailureHandler implements AuthenticationFailureHandler {
	
	@Resource
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) {
        this.saveException(request, exception);
//        response.setContentType("application/json;charset=UTF-8");
        // 这里写登录失败后的逻辑,可加验证码验证等
//        String errorInfo = "";
//        if (exception instanceof BadCredentialsException ||
//                exception instanceof UsernameNotFoundException) {
//            errorInfo = "账户名或者密码输入错误!";
//        } else if (exception instanceof LockedException) {
//            errorInfo = "账户被锁定,请联系管理员!";
//        } else if (exception instanceof CredentialsExpiredException) {
//            errorInfo = "密码过期,请联系管理员!";
//        } else if (exception instanceof AccountExpiredException) {
//            errorInfo = "账户过期,请联系管理员!";
//        } else if (exception instanceof DisabledException) {
//            errorInfo = "账户被禁用,请联系管理员!";
//        } else {
//            errorInfo = "登录失败!";
//        }
        // ajax请求认证方式
//        JSONObject resultObj = new JSONObject();
//        resultObj.putOnce("code", HttpStatus.UNAUTHORIZED.value());
//        resultObj.putOnce("msg",errorInfo);
//        resultObj.putOnce("exception",objectMapper.writeValueAsString(exception));
//        response.getWriter().write(resultObj.toString());
        try {
            this.getRedirectStrategy().sendRedirect(request, response, "/login");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这里插入图片描述

3.2.8 注销成功处理器

在这里插入图片描述

public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {

    @Override
    public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse response, Authentication authentication) {
        // 返回响应信息
        response.setStatus(HttpStatus.OK.value());
        response.setContentType("application/json;charset=UTF-8");
        try {
            response.getWriter().write("注销成功!");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这里插入图片描述

3.2.9 登录页面的 Form 表单

<form action="/user/login" method="post"> <!-- action 与配置类的 loginProcessingUrl 对应 -->
	<!-- name 与配置类设置的 usernameParameter 对应,不设置默认为 username -->
	用户名:<input type="text" name="uname"/><br/>
	<!-- name 与配置类设置的 passwordParameter 对应,不设置默认为 password -->
	密码:<input type="password" name="pword"/><br/>
	<input type="submit" value="提交"/>
</form>

3.3 基于角色或权限进行访问控制

3.3.1 hasAuthority() 方法

如果当前的主体具有指定的权限,则返回 true,否则返回 false

// 配置类
@Override
protected void configure(HttpSecurity http) throws Exception {
	http.authorizeRequests()
		// 不需要通过登录验证就可以被访问到的资源路径
		.antMatchers("/", "/hello", "/login").permitAll()
		// 前面是资源的访问路径,后面是资源的名称或资源 ID,需要主体带有 admin 权限才可访问
		.antMatchers("/test").hasAuthority("admin")
		.anyRequest().authenticated(); // 其他请求需要认证
}
// 实现类
@Service
public class MyUserDetailsServiceImpl implements UserDetailsService {
    @Resource
    private UserDAO userDAO;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        fan.springsecuritytest.entity.User selectOne = userDAO.selectOne(new QueryWrapper<fan.springsecuritytest.entity.User>().eq("username", username));
        if (ObjectUtils.isEmpty(selectOne)){
            throw new UsernameNotFoundException("用户名不存在!");
        }
        // 设置权限,为 role,不可访问需要 admin 权限的路径
        List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("role");
        return new User(selectOne.getUsername(), new BCryptPasswordEncoder().encode(selectOne.getPassword()), authorities);
    }
}

无权限访问, Forbidden 403
在这里插入图片描述

3.3.2 hasAnyAuthority() 方法

如果当前的主体有任何提供的角色(给定的作为一个逗号分隔的字符串列表)的话,返回 true。

// 配置类
@Override
protected void configure(HttpSecurity http) throws Exception {
	http.authorizeRequests()
		// 不需要通过登录验证就可以被访问到的资源路径
		.antMatchers("/", "/hello", "/login").permitAll()
		// 前面是资源的访问路径,后面是资源的名称或资源 ID,需要主体带有 admin或role 权限才可访问
		.antMatchers("/test").hasAnyAuthority("admin", "role")
		.anyRequest().authenticated(); // 其他请求需要认证
}

在这里插入图片描述

3.3.3 hasRole() 方法

如果用户具备给定角色就允许访问,否则出现 403。如果当前主体具有指定的角色,则返回 true。
在这里插入图片描述
在这里插入图片描述
由于底层源码给设定的 role 加上了前缀 “ROLE_”,所以给主体设定角色时,也要加上前缀

// 配置类
@Override
protected void configure(HttpSecurity http) throws Exception {
	http.authorizeRequests()
		// 不需要通过登录验证就可以被访问到的资源路径
		.antMatchers("/", "/hello", "/login").permitAll()
		// 前面是资源的访问路径,后面是资源的名称或资源 ID,需要主体为 sale 角色才可访问
		.antMatchers("/test").hasRole("sale")
		.anyRequest().authenticated(); // 其他请求需要认证
}
// 实现类
@Service
public class MyUserDetailsServiceImpl implements UserDetailsService {
    @Resource
    private UserDAO userDAO;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        fan.springsecuritytest.entity.User selectOne = userDAO.selectOne(new QueryWrapper<fan.springsecuritytest.entity.User>().eq("username", username));
        if (ObjectUtils.isEmpty(selectOne)){
            throw new UsernameNotFoundException("用户名不存在!");
        }
        // 角色加上前缀
        List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList("role, ROLE_sale");
        return new User(selectOne.getUsername(), new BCryptPasswordEncoder().encode(selectOne.getPassword()), authorities);
    }
}

3.3.4 hasAnyRole() 方法

表示用户具备任何一个角色都可以访问

// 配置类
@Override
protected void configure(HttpSecurity http) throws Exception {
	http.authorizeRequests()
		// 不需要通过登录验证就可以被访问到的资源路径
		.antMatchers("/", "/hello", "/login").permitAll()
		// 前面是资源的访问路径,后面是资源的名称或资源 ID,需要主体为 sale或sale1 角色才可访问
		.antMatchers("/test").hasAnyRole("sale", "sale1")
		.anyRequest().authenticated(); // 其他请求需要认证
}

3.3.5 自定义没有权限访问页面(非必要)

// 配置类
@Override
protected void configure(HttpSecurity http) throws Exception {
	// 没有权限访问跳转到该路径
	http.exceptionHandling().accessDeniedPage("/forbidden");
}

3.4 注解使用

3.4.1 @Secured

开启注解

@EnableGlobalMethodSecurity(securedEnabled=true)

可以加在启动类上,也可以在配置类上
在这里插入图片描述
@Secured 判断是否具有角色,只有具有该角色才可以进行访问,这里匹配的字符串需要添加前缀 “ROLE_“

// Controller 方法
@GetMapping("/demo01")
@ResponseBody
@Secured(value = {"ROLE_sale", "ROLE_sale1"}) // 加上注解
public String demo01(){
	return "demo01 请求";
}

3.4.2 @PreAuthorize

进入方法前的权限验证, @PreAuthorize 可以将登录用户的 roles/permissions 参数传到方法中

@EnableGlobalMethodSecurity(prePostEnabled = true)

// Controller 方法
@GetMapping("/demo01")
@ResponseBody
@PreAuthorize("hasAnyAuthority('role')")
public String demo01(){
	return "demo01 请求";
}

3.4.3 @PostAuthorize

@EnableGlobalMethodSecurity(prePostEnabled = true)

@PostAuthorize 注解使用并不多,在方法执行后再进行权限验证,适合验证带有返回值的权限

@GetMapping("/demo02")
@ResponseBody
@PostAuthorize("hasAnyAuthority('admin')")
public String demo02(){
	System.out.println("返回前执行的方法!");
	return "demo02 请求";
}

3.4.4 PreFilter

进入控制器之前对数据进行过滤,假如值取模 2 为 0,则输出

@RequestMapping("getTestPreFilter")
@PreAuthorize("hasRole('ROLE_管理员')")
@PreFilter(value = "filterObject.id%2==0")
@ResponseBody
public List<UserInfo> getTestPreFilter(@RequestBody List<UserInfo> list){
	list.forEach(t -> {
		System.out.println(t.getId() + "\t" + t.getUsername());
	});
	return list;
}

3.4.5 @PostFilter

权限验证之后对数据进行过滤 留下用户名是 admin1 的数据,表达式中的 filterObject 引用的是方法返回值 List 中的某一个元素

@GetMapping("/demo01")
@PreAuthorize("hasRole('ROLE_sale')")
@PostFilter("filterObject.username == 'admin1'")
@ResponseBody
public List<UserInfo> getAllUser(){
	ArrayList<UserInfo> list = new ArrayList<>();
	list.add(new UserInfo(1l,"admin1","6666"));
	list.add(new UserInfo(2l,"admin2","888"));
	return list;
}

3.5 基于数据库的记住我

在这里插入图片描述

3.5.1 SQL

jdbcTokenRepository.setCreateTableOnStartup(true);

在这里插入图片描述
该语句会自动在数据库中创建一个存放 Token 及相关信息的一个表,表名为 persistent_logins,也可以手动创建该表,不执行该语句。只有数据库中不存在该表需要创建表才执行。

CREATE TABLE `persistent_logins` (
  `username` varchar(64) NOT NULL,
  `series` varchar(64) NOT NULL,
  `token` varchar(64) NOT NULL,
  `last_used` timestamp NOT NULL,
  PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

3.5.2 添加记住我功能

在这里插入图片描述

import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Resource
    private UserDetailsService myUserDetailsServiceImpl;

	// 注入数据源
    @Resource
    private DataSource dataSource;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailsServiceImpl).passwordEncoder(passwordEncoder());
    }

    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

	@Bean
    public PersistentTokenRepository persistentTokenRepository(){
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        // 赋值数据源
        jdbcTokenRepository.setDataSource(dataSource);
        // 自动创建表, 第一次执行会创建,以后要执行就要删除掉!
        jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    	// 开启记住我功能
        http.rememberMe()
	        .userDetailsService(myUserDetailsServiceImpl)
	        .tokenRepository(persistentTokenRepository());
    }
}

3.5.3 登录页面

<form action="/user/login" method="post">
  用户名:<input type="text" name="uname"/><br/>
  密码:<input type="password" name="pword"/><br/>
  <input type="submit" value="提交"/>
  <!-- 设置 name 为 remeber-me -->
  记住我:<input type="checkbox" name="remember-me" title="记住密码"/><br/>
</form>

3.5.4 设置有效期

@Override
protected void configure(HttpSecurity http) throws Exception {
	// 开启记住我功能
	http.rememberMe()
		.userDetailsService(myUserDetailsServiceImpl)
		.tokenRepository(persistentTokenRepository())
		.tokenValiditySeconds(100); // 设置过期时间为 100 秒,单位为秒
}

3.6 用户注销

3.6.1 退出链接

<!-- 与配置类的 .logoutUrl("/logout") 对应 -->
<a href="/logout">退出</a>

3.6.2 配置类

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Resource
    private UserDetailsService myUserDetailsServiceImpl;
    
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailsServiceImpl).passwordEncoder(passwordEncoder());
    }

    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    	// 添加退出映射地址
        http.logout()
			.logoutUrl("/logout") // 与退出链接对应
			// 退出成功后跳转的地址,可以使用自定义退出成功处理器
			.logoutSuccessUrl("/login").permitAll();
    }
}

3.7 CSRF

3.7.1 概念

跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。

跨站请求攻击,简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并运行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去运行。这利用了 web 中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的。

从 Spring Security 4.0 开始,默认情况下会启用 CSRF 保护,以防止 CSRF 攻击应用程序,Spring Security CSRF 会针对 PATCH,POST,PUT 和 DELETE 方法进行防护。

3.7.2 Spring Security 防御机制

3.7.2.1 Csrf Token

用户登录时,系统发放一个 CsrfToken 值,用户携带该 CsrfToken 值与用户名、密码等参数完成登录。系统记录该会话的 CsrfToken 值,之后在用户的任何请求中,都必须带上该 CsrfToken 值,并由系统进行校验。

3.7.2.2 SpringSecurity 中使用 Csrf Token

Spring Security 通过注册一个 CsrfFilter 来专门处理 CSRF 攻击,在 Spring Security 中,CsrfToken 是一个用于描述 Token 值,以及验证时应当获取哪个请求参数或请求头字段的接口。

public interface CsrfToken extends Serializable {
	// 获取 _csrf 参数的 key
    String getHeaderName();
    String getParameterName();

	// 获取 _csrf 参数的 value
    String getToken();
}
public interface CsrfTokenRepository {
	// CsrfToken 的生成过程
    CsrfToken generateToken(HttpServletRequest request);

	// 保存 CsrfToken
    void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response);

	// 如何加载 CsrfToken
    CsrfToken loadToken(HttpServletRequest request);
}

实现类:
默认使用的是 DefaultCsrfToken
在这里插入图片描述
默认使用的是 HttpSessionCsrfTokenRepository
在这里插入图片描述

3.7.2.3 HttpSessionCsrfTokenRepository

在默认情况下,Spring Security 加载的是一个HttpSessionCsrfTokenRepository,将 CsrfToken 值存储在 HttpSession 中,并指定前端把 CsrfToken 值放在名为 “_csrf” 的请求参数或名为 “X-CSRF-TOKEN” 的请求头字段里(可以调用相应的设置方法来重新设定)。校验时,通过对比 HttpSession 内存储的 CsrfToken 值与前端携带的 CsrfToken 值是否一致,便能断定本次请求是否为 CSRF 攻击。

public final class HttpSessionCsrfTokenRepository implements CsrfTokenRepository {
    private static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
    private static final String DEFAULT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
    private static final String DEFAULT_CSRF_TOKEN_ATTR_NAME = HttpSessionCsrfTokenRepository.class.getName().concat(".CSRF_TOKEN");
    private String parameterName = "_csrf";
    private String headerName = "X-CSRF-TOKEN";
    private String sessionAttributeName;

    public HttpSessionCsrfTokenRepository() {
        this.sessionAttributeName = DEFAULT_CSRF_TOKEN_ATTR_NAME;
    }

    public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
        HttpSession session;
        if (token == null) {
            session = request.getSession(false);
            if (session != null) {
                session.removeAttribute(this.sessionAttributeName);
            }
        } else {
            session = request.getSession();
            session.setAttribute(this.sessionAttributeName, token);
        }

    }

    public CsrfToken loadToken(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        return session == null ? null : (CsrfToken)session.getAttribute(this.sessionAttributeName);
    }

    public CsrfToken generateToken(HttpServletRequest request) {
        return new DefaultCsrfToken(this.headerName, this.parameterName, this.createNewToken());
    }

    public void setParameterName(String parameterName) {
        Assert.hasLength(parameterName, "parameterName cannot be null or empty");
        this.parameterName = parameterName;
    }

    public void setHeaderName(String headerName) {
        Assert.hasLength(headerName, "headerName cannot be null or empty");
        this.headerName = headerName;
    }

    public void setSessionAttributeName(String sessionAttributeName) {
        Assert.hasLength(sessionAttributeName, "sessionAttributename cannot be null or empty");
        this.sessionAttributeName = sessionAttributeName;
    }

    private String createNewToken() {
        return UUID.randomUUID().toString();
    }
}
  1. saveToken 方法将 CsrfToken 保存在 HttpSession 中,将来再从 HttpSession 中取出和前端传来的参数做比较。
  2. loadToken 方法当然就是从 HttpSession 中读取 CsrfToken 出来。
  3. generateToken 是生成 CsrfToken 的过程,可以看到,生成的默认载体就是 DefaultCsrfToken,而 CsrfToken 的值则通过 createNewToken 方法生成,是一个 UUID 字符串。
  4. 在构造 DefaultCsrfToken 是还有两个参数 headerName 和 parameterName,这两个参数是前端保存参数的 key。

适用于前后端不分离的开发

<input type="hidden" th:if="${_csrf}!=null" th:value="${_csrf.token}" name="_csrf"/>
或者<input type="hidden" th:value="${_csrf.token}" th:name="${_csrf.parameterName}"> 

3.7.2.4 CookieCsrfTokenRepository

前后端分离开发需要 CsrfTokenRepository 的另一个实现类 CookieCsrfTokenRepository ,是一种更加灵活可行的方案,它将 CsrfToken 值存储在用户的 cookie 内。减少了服务器 HttpSession 存储的内存消耗,并且当用 cookie 存储 CsrfToken 值时,前端可以用 JavaScript 读取(需要设置该 cookie 的 httpOnly 属性为 false),而不需要服务器注入参数,在使用方式上更加灵活。

存储在 cookie 中是不会被 CSRF 利用的,cookie 只有在同域的情况下才能被读取,所以杜绝了第三方站点跨域获取 CsrfToken 值的可能。同时 CSRF 攻击本身是不知道 cookie 内容的,只是利用了当请求自动携带 cookie 时可以通过身份验证的漏洞。但服务器对 CsrfToken 值的校验并非取自 cookie,而是需要前端从 Cookie 中自己提取出来 _csrf 参数,然后拼接成参数传递给后端,单纯的将 Cookie 中的数据传到服务端是没用的。

  1. 配置的时候通过 withHttpOnlyFalse 方法获取 CookieCsrfTokenRepository 的实例,该方法会设置 Cookie 中的 HttpOnly 属性为 false,也就是允许前端通过 JS 操作 Cookie(否则就没有办法获取到 _csrf)。

    @Override 
    protected void configure(HttpSecurity http) throws Exception { 
        http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); 
    } 
    
  2. 可以采用 header 或者 param 的方式添加 csrf_token,下面示范从 cookie 中获取 token

    <form action="/executeLogin" method="post">
    	<input type="hidden" name="_csrf">
    	用户名<input type="text" name="username" class="lowin-input">
    	密码<input type="password" name="password" class="lowin-input">
    	记住我<input name="remember-me" type="checkbox" value="true" />
    	<input class="lowin-btn login-btn" type="submit">
    </form>
    <script>
        $(function () {
            var aCookie = document.cookie.split("; ");
            console.log(aCookie);
            for (var i=0; i < aCookie.length; i++){
                var aCrumb = aCookie[i].split("=");
                if ("XSRF-TOKEN" == aCrumb[0])
                    $("input[name='_csrf']").val(aCrumb[1]);
            }
        });
    </script>
    

3.8.2.5 LazyCsrfTokenRepository

对于常见的 GET 请求实际上是不需要 CSRF 攻击校验的,但是,每当 GET 请求到来时,下面这段代码都会执行:

if (missingToken) {
     csrfToken = this.tokenRepository.generateToken(request);
     this.tokenRepository.saveToken(csrfToken, request, response);
}

生成 CsrfToken 并保存,但实际上却没什么用,因为 GET 请求不需要 CSRF 攻击校验。所以,Spring Security 官方又推出了 LazyCsrfTokenRepository。

LazyCsrfTokenRepository 实际上不能算是一个真正的 CsrfTokenRepository,它是一个代理,可以用来增强 HttpSessionCsrfTokenRepository 或者 CookieCsrfTokenRepository 的功能:

public final class LazyCsrfTokenRepository implements CsrfTokenRepository {
    private static final String HTTP_RESPONSE_ATTR = HttpServletResponse.class.getName();
    private final CsrfTokenRepository delegate;

    public LazyCsrfTokenRepository(CsrfTokenRepository delegate) {
        Assert.notNull(delegate, "delegate cannot be null");
        this.delegate = delegate;
    }

    public CsrfToken generateToken(HttpServletRequest request) {
        return this.wrap(request, this.delegate.generateToken(request));
    }

    public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
        if (token == null) {
            this.delegate.saveToken(token, request, response);
        }

    }

    public CsrfToken loadToken(HttpServletRequest request) {
        return this.delegate.loadToken(request);
    }

    private CsrfToken wrap(HttpServletRequest request, CsrfToken token) {
        HttpServletResponse response = this.getResponse(request);
        return new LazyCsrfTokenRepository.SaveOnAccessCsrfToken(this.delegate, request, response, token);
    }

    private HttpServletResponse getResponse(HttpServletRequest request) {
        HttpServletResponse response = (HttpServletResponse)request.getAttribute(HTTP_RESPONSE_ATTR);
        Assert.notNull(response, () -> {
            return "The HttpServletRequest attribute must contain an HttpServletResponse for the attribute " + HTTP_RESPONSE_ATTR;
        });
        return response;
    }

    private static final class SaveOnAccessCsrfToken implements CsrfToken {
        private transient CsrfTokenRepository tokenRepository;
        private transient HttpServletRequest request;
        private transient HttpServletResponse response;
        private final CsrfToken delegate;

        SaveOnAccessCsrfToken(CsrfTokenRepository tokenRepository, HttpServletRequest request, HttpServletResponse response, CsrfToken delegate) {
            this.tokenRepository = tokenRepository;
            this.request = request;
            this.response = response;
            this.delegate = delegate;
        }

        public String getHeaderName() {
            return this.delegate.getHeaderName();
        }

        public String getParameterName() {
            return this.delegate.getParameterName();
        }

        public String getToken() {
            this.saveTokenIfNecessary();
            return this.delegate.getToken();
        }

        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            } else if (obj != null && this.getClass() == obj.getClass()) {
                LazyCsrfTokenRepository.SaveOnAccessCsrfToken other = (LazyCsrfTokenRepository.SaveOnAccessCsrfToken)obj;
                if (this.delegate == null) {
                    if (other.delegate != null) {
                        return false;
                    }
                } else if (!this.delegate.equals(other.delegate)) {
                    return false;
                }

                return true;
            } else {
                return false;
            }
        }

        public int hashCode() {
            int prime = true;
            int result = 1;
            int result = 31 * result + (this.delegate == null ? 0 : this.delegate.hashCode());
            return result;
        }

        public String toString() {
            return "SaveOnAccessCsrfToken [delegate=" + this.delegate + "]";
        }

        private void saveTokenIfNecessary() {
            if (this.tokenRepository != null) {
                synchronized(this) {
                    if (this.tokenRepository != null) {
                        this.tokenRepository.saveToken(this.delegate, this.request, this.response);
                        this.tokenRepository = null;
                        this.request = null;
                        this.response = null;
                    }

                }
            }
        }
    }
}
  1. generateToken 方法,该方法用来生成 CsrfToken,默认 CsrfToken 的载体是 DefaultCsrfToken,现在换成了 SaveOnAccessCsrfToken。
  2. SaveOnAccessCsrfToken 和 DefaultCsrfToken 并没有太大区别,主要是 getToken 方法有区别,在 SaveOnAccessCsrfToken 中,当开发者调用 getToken 想要去获取 csrfToken 时,才会去对 csrfToken 做保存操作(调用 HttpSessionCsrfTokenRepository 或者 CookieCsrfTokenRepository 的 saveToken 方法)。
  3. LazyCsrfTokenRepository 自己的 saveToken 则做了修改,相当于放弃了 saveToken 的功能,调用该方法并不会做保存操作。

在使用 Spring Security 时,如果对 csrf 不做任何配置,默认其实就是 LazyCsrfTokenRepository + HttpSessionCsrfTokenRepository 组合

3.7.3 参数校验

校验主要是通过 CsrfFilter 过滤器来进行,核心为 doFilterInternal() 方法

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        request.setAttribute(HttpServletResponse.class.getName(), response);
        CsrfToken csrfToken = this.tokenRepository.loadToken(request);
        boolean missingToken = csrfToken == null;
        if (missingToken) {
            csrfToken = this.tokenRepository.generateToken(request);
            this.tokenRepository.saveToken(csrfToken, request, response);
        }

        request.setAttribute(CsrfToken.class.getName(), csrfToken);
        request.setAttribute(csrfToken.getParameterName(), csrfToken);
        if (!this.requireCsrfProtectionMatcher.matches(request)) {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace("Did not protect against CSRF since request did not match " + this.requireCsrfProtectionMatcher);
            }

            filterChain.doFilter(request, response);
        } else {
            String actualToken = request.getHeader(csrfToken.getHeaderName());
            if (actualToken == null) {
                actualToken = request.getParameter(csrfToken.getParameterName());
            }

            if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
                this.logger.debug(LogMessage.of(() -> {
                    return "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request);
                }));
                AccessDeniedException exception = !missingToken ? new InvalidCsrfTokenException(csrfToken, actualToken) : new MissingCsrfTokenException(actualToken);
                this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
            } else {
                filterChain.doFilter(request, response);
            }
        }
    }
  1. 首先调用 tokenRepository.loadToken 方法读取 CsrfToken 出来,这个 tokenRepository 就是配置的 CsrfTokenRepository 实例,CsrfToken 存在 HttpSession 中,这里就从 HttpSession 中读取,CsrfToken 存在 Cookie 中,这里就从 Cookie 中读取。
  2. 如果调用 tokenRepository.loadToken 方法没有加载到 CsrfToken,那说明这个请求可能是第一次发起,则调用 tokenRepository.generateToken 方法生成 CsrfToken ,并调用 tokenRepository.saveToken 方法保存 CsrfToken。
  3. 这里还调用 request.setAttribute 方法存了一些值进去,这就是默认情况下,通过 JSP 或者 Thymeleaf 标签渲染 _csrf 的数据来源
  4. requireCsrfProtectionMatcher.matches() 方法则使用用来判断哪些请求方法需要做校验,默认情况下,“GET”, “HEAD”, “TRACE”, “OPTIONS” 方法是不需要校验的。
  5. 接下来获取请求中传递来的 CSRF 参数,先从请求头中获取,获取不到再从请求参数中获取
  6. 获取到请求传来的 CSRF 参数之后,再和一开始加载到的 csrfToken 做比较,如果不同的话,就抛出异常

3.7.4 CSRF 注销

开启 CSRF 后,不仅登录受到保护,注销也同样受到保护,因此同样需要带上 CsrfToken。

<form action="/logout" method="post">
    <input type="hidden" th:value="${_csrf.token}" name="_csrf"/>
    <input type="submit" value="退出">
</form>

4. SpringSecurity 微服务权限方案

4.1 概念

微服务,关键其实不仅仅是微服务本身,而是系统要提供一套基础的架构,这种架构使得微服务可以独立的部署、运行、升级,不仅如此,这个系统架构还让微服务与微服务之间在结构上“松耦合”,而在功能上则表现为一个统一的整体。这种所谓的“统一的整体”表现出来的是统一风格的界面,统一的权限管理,统一的安全策略,统一的上线过程,统一的日志和审计方法,统一的调度方式,统一的访问入口等等。微服务的目的是有效的拆分应用,实现敏捷开发和部署。

4.2 微服务认证与授权实现

4.2.1 认证授权过程分析

  1. 如果是基于 Session,那么 Spring-security 会对 cookie 里的 sessionid 进行解析,找到服务器存储的 session 信息,然后判断当前用户是否符合请求的要求。
  2. 如果是 token,则是解析出 token,然后将当前请求加入到 Spring Security 管理的权限信息中去
    在这里插入图片描述

如果系统的模块众多,每个模块都需要进行授权与认证,所以选择基于 token 的形式进行授权与认证,用户根据用户名密码认证成功,然后获取当前用户角色的一系列权限值,并以用户名为 key,权限列表为 value 的形式存入 redis 缓存中,根据用户名相关信息生成 token 返回,浏览器将 token 记录到 cookie 中,每次调用 api 接口都默认将 token 携带到 header 请求头中,Spring-security 解析 header 头获取 token 信息,解析 token 获取当前用户名,根据用户名就可以从 redis 中获取权限列表,这样 Spring-security 就能够判断当前请求是否有权限访问。

4.2.2 权限管理数据模型

在这里插入图片描述

4.2.4 具体实现

4.2.4.1 实体类

@Data
@ApiModel(description = "用户实体类")
public class User implements Serializable {

    private String username; // 用户名
    
    private String password; // 密码
}
@Data
@ApiModel(description = "角色实体类")
public class Role implements Serializable {

    private String roleName; // 角色名称

    private String roleCode; // 角色编码
}
@Data
@ApiModel(value="Permission对象", description="权限实体类")
public class Permission implements Serializable {

    @ApiModelProperty(value = "所属上级")
    private String pid;

    @ApiModelProperty(value = "名称")
    private String name;

    @ApiModelProperty(value = "类型(1:菜单,2:按钮)")
    private Integer type;

    @ApiModelProperty(value = "权限值")
    private String permissionValue;

    @ApiModelProperty(value = "访问路径")
    private String path;

    @ApiModelProperty(value = "状态(0:禁止,1:正常)")
    private Integer status;

    @ApiModelProperty(value = "层级")
    @TableField(exist = false)
    private Integer level;

    @ApiModelProperty(value = "下级")
    @TableField(exist = false)
    private List<Permission> children;

    @ApiModelProperty(value = "是否选中")
    @TableField(exist = false)
    private boolean isSelect;
}
@Data
@NoArgsConstructor
@ApiModel(description = "SecurityUser实体类,UserDetailsService 接口的返回对象")
public class SecurityUser implements UserDetails {

    // 从数据库中查询的当前用户
    private transient User currentUser;

    // 从数据库中查询的当前用户权限列表
    private List<String> permissionValueList;

    public SecurityUser(User user) {
        if (user != null) {
            this.currentUserInfo = user;
        }
    }

    @Override
    // 将数据库中查询到的用户权限列表 List<String> 转为 Collection<GrantedAuthority>
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        for(String permissionValue : permissionValueList) {
            if(StringUtils.isEmpty(permissionValue)) continue;
            SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
            authorities.add(authority);
        }

        return authorities;
    }

    @Override
    public String getPassword() {
        return currentUser.getPassword();
    }

    @Override
    public String getUsername() {
        return currentUser.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

4.2.4.2 TokenLoginFilter 认证

进行认证的 Filter,通过 AuthenticationManager 的 authenticate 方法来进行用户认证,所以需要把 AuthenticationManager 注入容器或者直接传入 authenticationManager() 方法

http.addFilter(new TokenLoginFilter(authenticationManager(), tokenManager, redisTemplate));

认证成功的话生成一个 JWT,放入响应中返回。并且为了让用户下回请求时能通过 JWT 识别出具体的是哪个用户,需要把用户相关信息存入 Redis。

public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {

    private TokenManager tokenManager; // Token 工具类
    private RedisTemplate redisTemplate;
    private AuthenticationManager authenticationManager; // 用来认证

    public TokenLoginFilter(AuthenticationManager authenticationManager, TokenManager tokenManager, RedisTemplate redisTemplate) {
        this.authenticationManager = authenticationManager;
        this.tokenManager = tokenManager;
        this.redisTemplate = redisTemplate;
        this.setPostOnly(false);
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/acl/login","POST"));
    }

    // 1. 获取表单提交用户名和密码
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
        	// 获取表单提交的数据
            User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
            // 将用户名和密码传给 UserDetailsService 进行认证,认证成功返回认证信息 Authentication 
            Authentication authenticate = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), new ArrayList<>()));
            return authenticate;
        } catch (IOException e) {
            e.printStackTrace(); throw new RuntimeException();
        }
    }

    // 2. 认证成功之后调用的方法
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // 认证成功,得到认证成功之后的用户信息
        SecurityUser user = (SecurityUser)authResult.getPrincipal();
        // 根据用户名生成 Token
        String jwt = tokenManager.createToken(user.getCurrentUser().getUsername());
        // 把用户名称和用户权限列表放到 Redis
        redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(),user.getPermissionValueList());
        // 返回 Token
        ResponseUtil.out(response, R.ok().data("Token", jwt));
    }

    // 3. 认证失败调用的方法
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        ResponseUtil.out(response, R.error());
    }
}

4.2.4.3 UserDetailsServiceImpl

实现 UserDetailsService 接口,根据用户名从数据库查出用户信息和用户信息和用户权限信息。

public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private UserService userService;

    @Autowired
    private PermissionService permissionService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户名查询用户信息
        User user = userService.selectByUsername(username);
        // 判断
        if(user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        // 根据用户查询用户权限列表
        List<String> permissionValueList = permissionService.selectPermissionValueByUserId(user.getId());
        
        SecurityUser securityUser = new SecurityUser();
        securityUser.setCurrentUser(user); // 传入从数据库中查出的用户
        securityUser.setPermissionValueList(permissionValueList); // 传入从数据库中查出的用户权限列表
        return securityUser;
    }
}

4.2.4.4 TokenManager

操作 Token 的工具类

@Component
public class TokenManager {
    // Token有效时长
    private long tokenEcpiration = 24*60*60*1000;
    // Token 秘钥
    private String tokenSignKey = "123456";
    // 1. 根据用户名生成 Token
    public String createToken(String username) {
        String token = Jwts.builder().setSubject(username)
                .setExpiration(new Date(System.currentTimeMillis() + tokenExpiration))
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY).compact();
        return token;
    }
    //2 根据token字符串得到用户信息
    public String getUserInfoFromToken(String token) {
        String userInfo = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody().getSubject();
        return userInfo;
    }
    // 3. 删除token
    public void removeToken(String token) { }
}

4.2.4.5 TokenAuthFilter 请求拦截

进行授权的 Filter,过滤器会去获取请求头中的 Token,对 Token 进行解析取出其中的 username。使用 username 去 Redis 中获取对应的 权限列表。然后封装 Authentication 对象存 SecurityContextHolder。

在 Spring Security中,会使用默认的 FilterSecurityInterceptor 来进行权限校验。在 FilterSecurityInterceptor 中会从 SecurityContextHolder 获取其中的 Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。在项目中只需要把当前登录用户的权限信息也存入Authentication。然后设置资源所需要的权限即可。

@AllArgsConstructor
public class TokenAuthFilter extends OncePerRequestFilter {

    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 获取当前认证成功用户权限信息
        UsernamePasswordAuthenticationToken authRequest = getAuthentication(request);
        // 判断如果有权限信息,放到权限上下文中,是一个 Authentication 对象
        if(authRequest != null) {
            SecurityContextHolder.getContext().setAuthentication(authRequest);
        }
        chain.doFilter(request,response);
    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        // 从 header获取 Token
        String token = request.getHeader("token");
        if(token != null) {
            // 从 Token 获取用户名
            String username = tokenManager.getUserInfoFromToken(token);
            // 根据用户名从 Redis 获取对应权限列表
            List<String> permissionValueList = (List<String>)redisTemplate.opsForValue().get(username);
            Collection<GrantedAuthority> authority = new ArrayList<>();
            for(String permissionValue : permissionValueList) {
                SimpleGrantedAuthority auth = new SimpleGrantedAuthority(permissionValue);
                authority.add(auth);
            }
            // Authentication 的实现类
            return new UsernamePasswordAuthenticationToken(username, token, authority);
        }
        return null;
    }
}

4.2.4.6 DefaultPasswordEncoder 加密

加密处理

@Component
public class DefaultPasswordEncoder implements PasswordEncoder {

    // 进行MD5加密
    @Override
    public String encode(CharSequence charSequence) {
        return MD5.encrypt(charSequence.toString());
    }
    // 进行密码比对
    @Override
    public boolean matches(CharSequence charSequence, String encodedPassword) {
        return encodedPassword.equals(MD5.encrypt(charSequence.toString()));
    }
}

4.2.4.7 CustomerLogoutHandler 注销

可以和注销成功处理器一起使用,LogoutHandler --> LogoutSuccessHandler

@AllArgsConstructor
public class CustomerLogoutHandler implements LogoutHandler {
    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        // 1. 从 header 里面获取 Token
        // 2. Token 不为空(移除 Token,从 redis 删除 Token)
        String token = request.getHeader("token");
        if(token != null) {
            // 移除
            tokenManager.removeToken(token);
            // 从 Token获取用户名
            String username = tokenManager.getUserInfoFromToken(token);
            // 删除 Redis 中的用户相关信息
            redisTemplate.delete(username);
        }
        ResponseUtil.out(response, R.ok());
    }
}

4.2.4.8 UnAuthorizedEntryPoint 和 UnAccessDeniedHandler 异常处理

在认证失败或者是授权失败的情况下返回异常处理

在 Spring Security 中,如果在认证或者授权的过程中出现了异常会被 ExceptionTranslationFilter 捕获到。在 ExceptionTranslationFilter 中会去判断是认证失败还是授权失败出现的异常。

  • 如果是认证过程中出现的异常会被封装成 AuthenticationException 然后调用 AuthenticationEntryPoint 对象的方法去进行异常处理。
  • 如果是授权过程中出现的异常会被封装成 AccessDeniedException 然后调用 AccessDeniedHandler 对象的方法去进行异常处理。

所以如果需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint 和 AccessDeniedHandler 然后配置给 Spring Security 即可。

// 认证
public class UnAuthEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        ResponseUtil.out(httpServletResponse, R.error(HttpStatus.UNAUTHORIZED.value(), "认证失败请重新登录"));
    }
}

// 授权
@Component
public class UnAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
    	ResponseUtil.out(httpServletResponse, R.error(HttpStatus.FORBIDDEN.value(), "权限不足"));
    }
}

4.2.4.9 配置类

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@AllArgsConstructor
public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter {

    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;
    private DefaultPasswordEncoder defaultPasswordEncoder;
    private UserDetailsService userDetailsServiceImpl;
    private UnAccessDeniedHandler unAccessDeniedHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.exceptionHandling()
                .authenticationEntryPoint(new UnauthEntryPoint())
                .accessDeniedHandler(unAccessDeniedHandler) // 异常处理
                .and().csrf().disable()
                .authorizeRequests()
                .anyRequest().authenticated()
                .and().logout().logoutUrl("/admin/acl/index/logout")
                // 注销处理器
                .addLogoutHandler(new CustomerLogoutHandler(tokenManager,redisTemplate))
                .logoutSuccessHandler(new CustomerCLogoutSuccessHandler()).and()
                // 认证处理器
                .addFilter(new TokenLoginFilter(authenticationManager(), tokenManager, redisTemplate))
                // 授权处理器
                .addFilterBefore(new TokenAuthFilter(tokenManager, redisTemplate), UsernamePasswordAuthenticationFilter.class);
    }

    // 调用 userDetailsService 和密码处理
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsServiceImpl).passwordEncoder(defaultPasswordEncoder);
    }
    // 不进行认证的路径,可以直接访问
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/api/**");
    }
}

4.2.4.10 Controller

@GetMapping("/hello")
@PreAuthorize("hasAuthority('system:dept:list')")
// @PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')")
// @PreAuthorize("hasRole('system:dept:list')")
// @PreAuthorize("hasAnyRole('admin','system:dept:list')")
    public String hello(){
        return "hello";
}

4.2.4.11 跨域

浏览器出于安全的考虑,使用 XMLHttpRequest 对象发起 HTTP 请求时必须遵守同源策略,否则就是跨域的 HTTP 请求,默认情况下是被禁止的。同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。

前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题,需要进行处理让前端能进行跨域请求。

// SpringBoot 配置类
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
      // 设置允许跨域的路径
        registry.addMapping("/**")
                // 设置允许跨域请求的域名
                .allowedOriginPatterns("*")
                // 是否允许 cookie
                .allowCredentials(true)
                // 设置允许的请求方式
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的 header 属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(3600);
    }
}

开启 Spring Security 的跨域访问

@Override
protected void configure(HttpSecurity http) throws Exception {
	//允许跨域
	http.cors();
}
  Java知识库 最新文章
计算距离春节还有多长时间
系统开发系列 之WebService(spring框架+ma
springBoot+Cache(自定义有效时间配置)
SpringBoot整合mybatis实现增删改查、分页查
spring教程
SpringBoot+Vue实现美食交流网站的设计与实
虚拟机内存结构以及虚拟机中销毁和新建对象
SpringMVC---原理
小李同学: Java如何按多个字段分组
打印票据--java
上一篇文章      下一篇文章      查看所有文章
加:2022-03-22 20:22:50  更:2022-03-22 20:25:56 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/24 7:14:21-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码