SpringSecurity学习

SpringSecurity

一、跨域概念

在说跨域之前先说一下同源策略,同源指的是两个URL的协议、域名、端口相同,浏览器出于安全方面的考虑,不同源的客户端脚本在没有明确授权的情况下,不能读写对方资源

跨域分为以下三种情况

http://127.0.0.1:8080 --> https://127.0.0.1:8080   协议跨域
http://127.0.0.1:8080 --> http://127.0.0.2:8080    IP跨域
http://127.0.0.1:8080 --> http://127.0.0.1:8081    端口跨域

二、CSRF概念

CSRF 又称 跨站请求伪造,也可称为一键式攻击。

CSRF 攻击是一种挟持用户在当前已登录的浏览器上发送恶意请求的攻击方法。简单来说,CSRF是致击者通过一些技术手段欺骗用户的浏览器,去访问 一个用户曾经认证过的网站 并执行恶意请求,例如发送邮件、发消息、甚至财产操作 (如转账和购买商品)。由于客户端(浏览器)已经在该网站上认证过,所以该网站会认为是真正用户在操作而执行请求(实际上这个并非用户的本意)。

举个例子

假设 liyuejun 现在登录了某银行的网站准备完成一项转账操作,转账的链接如下:

https: //bank .xxx .com/withdraw?account=liyuejun&amount=1000&for=zhangsan

可以看到,这个链接是想从 liyuejun 这个账户下转账 1000 元到 zhangsan 账户下,假设liyuejun 没有注销登录该银行的网站,就在同一个浏览器新的选项卡中打开了一个危险网站,这个危险网站中有一幅图片,代码如下:

< img src="C:/Users/BLA/Desktop/SpringSecurity/https://bank.xxx.com/withdraw">

一旦用户打开了这个网站,这个图片链接中的请求就会自动发送出去。由于是同一个浏览器并且用户尚未注销登录,所以该请求会自动携带上对应的有效的 Cookie 信息,进而完成一次转账操作。这就是跨站请求伪造。

三、权限管理

权限管理概念:
权限管理包括用户身份认证授权两部分,简称认证授权。对于需要访问控制的资源用户首先经过身份认证,认证通过后用户具有该资源的访问权限方可访问。

认证:
身份认证,就是判断一个用户是否为合法用户的处理过程。最常用的简单身份认证方式是系统通过核对用户输入的用户名和口令,看其是否与系统中存储的该用户的用户名和口令一致,来判断用户身份是否正确。

授权:
授权,即访问控制,控制谁能访问哪些资源。主体进行身份认证后需要分配权限方可访问系统的资源,对于某些资源没有权限是无法访问的。

目前企业的解决方案

  • Shiro
    • Shiro 本身是一个老牌的安全管理框架,有着众多的优点,例如轻量、简单、易于集成、可以在JavaSE环境中使用等。不过,在微服务时代,Shiro 就显得力不从心了,在微服务面前和扩展方面,无法充分展示自己的优势。
  • 开发者自定义
    • 也有很多公司选择自定义权限,即自己开发权限管理。但是一个系统的安全,不仅仅是登录和权限控制这么简单,我们还要考虑种各样可能存在的网络政击以及防彻策略,从这个角度来说,开发者白己实现安全管理也并非是一件容易的事情,只有大公司才有足够的人力物力去支持这件事情。
  • Spring Security
    • Spring Security,作为spring 家族的一员,在和 Spring 家族的其他成员如 Spring Boot Spring Clond等进行整合时,具有其他框架无可比拟的优势,同时对 OAuth2 有着良好的支持,再加上Spring Cloud对 Spring Security的不断加持(如推出 Spring Cloud Security ),让 Spring Securiy 不知不觉中成为微服务项目的首选安全管理方案。

官网:https://spring.io/projects/spring-security

四、整体架构

1、认证

AuthenticationManager

在Spring Security中认证是由AuthenticationManager接口来负责的,接口定义为:
image.png

  • 返回 Authentication 表示认证成功
  • 返回 AuthenticationException 异常,表示认证失败。

image.png

  • AuthenticationManager 主要实现类为 ProviderManager,在 ProviderManager 中管理了众多 AuthenticationProvider 实例。

image.png

  • 在一次完整的认证流程中,Spring Security 允许存在多个AuthenticationProvider ,用来实现多种认证方式,这些 AuthenticationProvider 都是由 ProviderManager 进行统一管理的。

Authentication
认证以及认证成功的信息主要是由 Authentication 的实现类进行保存的,其接口定义为:
image.png

  • getAuthorities 获取用户权限信息
  • getCredentials 获取用户凭证信息,一般指密码
  • getDetails 获取用户详细信息
  • getPrincipal 获取用户身份信息,用户名、用户对象等
  • isAuthenticated   用户是否认证成功

SecurityContextHolder
image.png
SecurityContextHolder 用来获取登录之后用户信息。Spring Security 会将登录用户数据保存在 Session 中。但是,为了使用方便,Spring Security在此基础上还做了一些改进,其中最主要的一个变化就是线程绑定。当用户登录成功后,Spring Security 会将登录成功的用户信息保存到 SecurityContextHolder 中。SecurityContextHolder 中的数据保存默认是通过ThreadLocal 来实现的,使用 ThreadLocal 创建的变量只能被当前线程访问,不能被其他线程访问和修改,也就是用户数据和请求线程绑定在一起。当登录请求处理完毕后,Spring Security 会将 SecurityContextHolder 中的数据拿出来保存到 Session 中,同时将 SecurityContextHolder 中的数据清空。以后每当有请求到来时,Spring Security 就会先从 Session 中取出用户登录数据,保存到 SecurityContextHolder 中,方便在该请求的后续处理过程中使用,同时在请求结束时将 SecurityContextHolder 中的数据拿出来保存到 Session 中,然后将 Security SecurityContextHolder 中的数据清空。这一策略非常方便用户在 Controller、Service 层以及任何代码中获取当前登录用户数据。

2、授权

暂时还没看

五、整合security

<!--引入spring security依赖-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

再次启动程序后,便会弹出该界面
image.png

控制台也会随之多出一串代码
image.png

- 默认用户名为: user
- 默认密码为:  控制台打印的 uuid

image.png

引入spring security依赖之后的一些疑问

1、为什么引入 Spring Security 之后没有做任何配置,所有请求就需要认证呢?
2、在项目中明明没有登录界面,登录界面怎么来的呢?
3、为什么使用 user 和 控制台密码 能登陆,登录时验证数据源存在哪里呢?

1、过滤链

首先,在security中,认证 和 授权 都是基于 过滤器 去实现的
image.png

image.png

  • 需要注意的是,默认过滤器并不是直接放在 Web 项目的原生过滤器链中,而是通过一个FliterChainProxy 来统一管理。Spring Security 中的过滤器链通过 FilterChainProxy 嵌入到 Web项目的原生过滤器链中。FilterChainProxy 作为一个顶层的管理者,将统一管理 Security Filter。FilterChainProxy 本身是通过 Spring 框架提供的 DelegatingFilterProxy 整合到原生的过滤器链中。
  • DelegatingFilterProxy的另一个好处是,它允许延迟查找Filterbean实例。这一点很重要,因为容器需要在启动容器之前注册Filter实例。然而,Spring通常使用ContextLoaderListener来加载Spring Beans,这要等到需要注册Filter实例之后才能完成。

2、Security Filters

那么在 Spring Security 中给我们提供那些过滤器? 默认情况下那些过滤器会被加载呢?
官网是这样说的:

过滤器 过滤器作用 默认是否加载
ChannelProcessingFilter 过滤请求协议 HTTP 、HTTPS NO
WebAsyncManagerIntegrationFilter 将 WebAsyncManger 与 SpringSecurity 上下文进行集成 YES
SecurityContextPersistenceFilter 在处理请求之前,将安全信息加载到 SecurityContextHolder 中 YES
HeaderWriterFilter 处理头信息加入响应中 YES
CorsFilter 处理跨域问题 NO
CsrfFilter 处理 CSRF 攻击 YES
LogoutFilter 处理注销登录 YES
OAuth2AuthorizationRequestRedirectFilter 处理 OAuth2 认证重定向 NO
Saml2WebSsoAuthenticationRequestFilter 处理 SAML 认证 NO
X509AuthenticationFilter 处理 X509 认证 NO
AbstractPreAuthenticatedProcessingFilter 处理预认证问题 NO
CasAuthenticationFilter 处理 CAS 单点登录 NO
OAuth2LoginAuthenticationFilter 处理 OAuth2 认证 NO
Saml2WebSsoAuthenticationFilter 处理 SAML 认证 NO
UsernamePasswordAuthenticationFilter 处理表单登录 YES
OpenIDAuthenticationFilter 处理 OpenID 认证 NO
DefaultLoginPageGeneratingFilter 配置默认登录页面 YES
DefaultLogoutPageGeneratingFilter 配置默认注销页面 YES
ConcurrentSessionFilter 处理 Session 有效期 NO
DigestAuthenticationFilter 处理 HTTP 摘要认证 NO
BearerTokenAuthenticationFilter 处理 OAuth2 认证的 Access Token NO
BasicAuthenticationFilter 处理 HttpBasic 登录 YES
RequestCacheAwareFilter 处理请求缓存 YES
SecurityContextHolderAwareRequestFilter 包装原始请求 YES
JaasApiIntegrationFilter 处理 JAAS 认证 NO
RememberMeAuthenticationFilter 处理 RememberMe 登录 NO
AnonymousAuthenticationFilter 配置匿名认证 YES
OAuth2AuthorizationCodeGrantFilter 处理OAuth2认证中授权码 NO
SessionManagementFilter 处理 session 并发问题 YES
ExceptionTranslationFilter 处理认证/授权中的异常 YES
FilterSecurityInterceptor 处理授权相关 YES
SwitchUserFilter 处理账户切换 NO

可以看出,Spring Security 提供了 30 多个过滤器。默认情况下Spring Boot 在对 Spring Security 进入自动化配置时,会创建一个名为 SpringSecurityFilerChain 的过滤器,并注入到 Spring 容器中,这个过滤器将负责所有的安全管理,包括用户认证、授权、重定向到登录页面等。具体可以参考WebSecurityConfiguration的源码:
springSecurityFilerChain的bean在 WebSecurityConfiguration 配置类里
image.png

3、SpringBootWebSecurityConfiguration

SpringBootWebSecurityConfiguration 为 spring boot 自动配置类

image.png
@ConditionalOnWebApplication(type = Type.SERVLET) 在servlet环境下便自动注入该配置类

@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class SecurityFilterChainConfiguration {
	
    @Bean
    @Order(SecurityProperties.BASIC_AUTH_ORDER)
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        //默认情况下把所有请求都拦截下来,进行认证
        http.authorizeRequests().anyRequest().authenticated();
        http.formLogin();
        http.httpBasic();
        return http.build();
    }

}

默认生成 defaultSecurityFilterChain 配置的条件如下:
进入@ConditionalOnDefaultWebSecurity 注解可以看出

class DefaultWebSecurityCondition extends AllNestedConditions {

	DefaultWebSecurityCondition() {
		super(ConfigurationPhase.REGISTER_BEAN);
	}

    //classpath需要有这两个文件
	@ConditionalOnClass({ SecurityFilterChain.class, HttpSecurity.class })
	static class Classes {

	}

    //没有自定义WebSecurityConfigurerAdapter.class和SecurityFilterChain.class
	@ConditionalOnMissingBean({
			org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter.class,
			SecurityFilterChain.class })
	@SuppressWarnings("deprecation")
	static class Beans {

	}
}

  • 条件一 classpath中存在 SecurityFilterChain.class, HttpSecurity.class(引入security就有)
  • 条件二 没有自定义 WebSecurityConfigurerAdapter.class, SecurityFilterChain.class

需要满足上面两个条件才可默认生成 defaultSecurityFilterChain 配置。

WebSecurityConfigurerAdapter 这个类及其重要,用来扩展 Spring Security 所有默认配置,Spring Security 核心配置都在这个类中,如果要对 Spring Security 进行自定义配置(比如把不想认证的某些请求,如swagger给开放),就要自定义这个类实例,通过覆盖类中方法达到修改默认配置的目的。

因此,这边是 为什么引入 Spring Security 之后没有做任何配置,所有请求就需要认证的答案,在项目中明明没有登录界面,登录界面怎么来的 这个答案便是走了默认的过滤器 DefaultLoginPageGeneratingFilter
DefaultLoginPageGeneratingFilter 的 generateLoginPageHtml方法:

private String generateLoginPageHtml(HttpServletRequest request, boolean loginError, boolean logoutSuccess) {
		String errorMsg = "Invalid credentials";
		if (loginError) {
			HttpSession session = request.getSession(false);
			if (session != null) {
				AuthenticationException ex = (AuthenticationException) session
						.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
				errorMsg = (ex != null) ? ex.getMessage() : "Invalid credentials";
			}
		}
		String contextPath = request.getContextPath();
		StringBuilder sb = new StringBuilder();
		sb.append("<!DOCTYPE html>\n");
		sb.append("<html lang=\"en\">\n");
		sb.append("  <head>\n");
		sb.append("    <meta charset=\"utf-8\">\n");
		sb.append("    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n");
		sb.append("    <meta name=\"description\" content=\"\">\n");
		sb.append("    <meta name=\"author\" content=\"\">\n");
		sb.append("    <title>Please sign in</title>\n");
		sb.append("    <link href=\"https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css\" "
				+ "rel=\"stylesheet\" integrity=\"sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M\" crossorigin=\"anonymous\">\n");
		sb.append("    <link href=\"https://getbootstrap.com/docs/4.0/examples/signin/signin.css\" "
				+ "rel=\"stylesheet\" crossorigin=\"anonymous\"/>\n");
		sb.append("  </head>\n");
		sb.append("  <body>\n");
		sb.append("     <div class=\"container\">\n");
		if (this.formLoginEnabled) {
			sb.append("      <form class=\"form-signin\" method=\"post\" action=\"" + contextPath
					+ this.authenticationUrl + "\">\n");
			sb.append("        <h2 class=\"form-signin-heading\">Please sign in</h2>\n");
			sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + "        <p>\n");
			sb.append("          <label for=\"username\" class=\"sr-only\">Username</label>\n");
			sb.append("          <input type=\"text\" id=\"username\" name=\"" + this.usernameParameter
					+ "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n");
			sb.append("        </p>\n");
			sb.append("        <p>\n");
			sb.append("          <label for=\"password\" class=\"sr-only\">Password</label>\n");
			sb.append("          <input type=\"password\" id=\"password\" name=\"" + this.passwordParameter
					+ "\" class=\"form-control\" placeholder=\"Password\" required>\n");
			sb.append("        </p>\n");
			sb.append(createRememberMe(this.rememberMeParameter) + renderHiddenInputs(request));
			sb.append("        <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n");
			sb.append("      </form>\n");
		}
		if (this.openIdEnabled) {
			sb.append("      <form name=\"oidf\" class=\"form-signin\" method=\"post\" action=\"" + contextPath
					+ this.openIDauthenticationUrl + "\">\n");
			sb.append("        <h2 class=\"form-signin-heading\">Login with OpenID Identity</h2>\n");
			sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + "        <p>\n");
			sb.append("          <label for=\"username\" class=\"sr-only\">Identity</label>\n");
			sb.append("          <input type=\"text\" id=\"username\" name=\"" + this.openIDusernameParameter
					+ "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n");
			sb.append("        </p>\n");
			sb.append(createRememberMe(this.openIDrememberMeParameter) + renderHiddenInputs(request));
			sb.append("        <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n");
			sb.append("      </form>\n");
		}
		if (this.oauth2LoginEnabled) {
			sb.append("<h2 class=\"form-signin-heading\">Login with OAuth 2.0</h2>");
			sb.append(createError(loginError, errorMsg));
			sb.append(createLogoutSuccess(logoutSuccess));
			sb.append("<table class=\"table table-striped\">\n");
			for (Map.Entry<String, String> clientAuthenticationUrlToClientName : this.oauth2AuthenticationUrlToClientName
					.entrySet()) {
				sb.append(" <tr><td>");
				String url = clientAuthenticationUrlToClientName.getKey();
				sb.append("<a href=\"").append(contextPath).append(url).append("\">");
				String clientName = HtmlUtils.htmlEscape(clientAuthenticationUrlToClientName.getValue());
				sb.append(clientName);
				sb.append("</a>");
				sb.append("</td></tr>\n");
			}
			sb.append("</table>\n");
		}
		if (this.saml2LoginEnabled) {
			sb.append("<h2 class=\"form-signin-heading\">Login with SAML 2.0</h2>");
			sb.append(createError(loginError, errorMsg));
			sb.append(createLogoutSuccess(logoutSuccess));
			sb.append("<table class=\"table table-striped\">\n");
			for (Map.Entry<String, String> relyingPartyUrlToName : this.saml2AuthenticationUrlToProviderName
					.entrySet()) {
				sb.append(" <tr><td>");
				String url = relyingPartyUrlToName.getKey();
				sb.append("<a href=\"").append(contextPath).append(url).append("\">");
				String partyName = HtmlUtils.htmlEscape(relyingPartyUrlToName.getValue());
				sb.append(partyName);
				sb.append("</a>");
				sb.append("</td></tr>\n");
			}
			sb.append("</table>\n");
		}
		sb.append("</div>\n");
		sb.append("</body></html>");
		return sb.toString();
	}

4、默认用户生成

1、还是继续查看 SpringBootWebSecurityConfiguration 配置类的 defaultSecurityFilterChain方法:

@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class SecurityFilterChainConfiguration {
	
    @Bean
    @Order(SecurityProperties.BASIC_AUTH_ORDER)
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        //默认情况下把所有请求都拦截下来,进行认证
        http.authorizeRequests().anyRequest().authenticated();
        //表单登录认证
        http.formLogin();
        http.httpBasic();
        return http.build();
    }

}

2、点击 http.formLogin()
image.png
3、再继续点击 UsernamePasswordAuthenticationFilter() 该过滤器
点开之后查看该类的 attempAuthentication 方法,得知实际调用 AuthenticationManager 中 authenticate 方法。
image.png
4、点进去遍发现是调用 ProviderManager 类中方法 authenticate
image.png
5、调用了 ProviderManager 实现类中 AbstractUserDetailsAuthenticationProvider类中方法
image.png
6、点击 retrieveUser()方法,进入了 DaoAuthenticationProvider 类里
image.png
7、最终得知,调用的是 UserDetailService 接口的 loadUserByUsername的方法
image.png
看到这里就知道默认实现是基于 InMemoryUserDetailsManager 这个类,也就是内存的实现!(若以后想基于数据库,得自定义配置)

5、UserDetailService

用来修改默认认证的数据源信息
通过刚才源码分析也能得知 UserDetailService 是顶层父接口,接口中 loadUserByUserName 方法是用来在认证时进行用户名认证方法,默认实现使用是内存实现,如果想要修改数据库实现我们只需要自定义 UserDetailService 实现,最终返回 UserDetails 实例即可。

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

image.png

我们来看看这个自动配置类 UserDetailServiceAutoConfigutation:

@AutoConfiguration
@ConditionalOnClass(AuthenticationManager.class)
@ConditionalOnBean(ObjectPostProcessor.class)
@ConditionalOnMissingBean(
		value = { AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class,
				AuthenticationManagerResolver.class },
		type = { "org.springframework.security.oauth2.jwt.JwtDecoder",
				"org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector",
				"org.springframework.security.oauth2.client.registration.ClientRegistrationRepository",
				"org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository" })
public class UserDetailsServiceAutoConfiguration {

	private static final String NOOP_PASSWORD_PREFIX = "{noop}";

	private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");

	private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);

	@Bean
	@Lazy
	public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
			ObjectProvider<PasswordEncoder> passwordEncoder) {
        //获取配置类的用户
		SecurityProperties.User user = properties.getUser();
		List<String> roles = user.getRoles();
		return new InMemoryUserDetailsManager(
				User.withUsername(user.getName()).password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
						.roles(StringUtils.toStringArray(roles)).build());
	}
    。。。
}

UserDetailServiceAutoConfigutation 配置类自动注入的条件如下:

  1. 从自动配置源码中得知当 classpath 下需要存在 AuthenticationManager 类
  2. 当前项目中,系统没有提供 AuthenticationManager.class、 AuthenticationProvider.class、UserDetailsService.class、AuthenticationManagerResolver.class、实例

默认情况下都会满足,此时Spring Security会提供一个 InMemoryUserDetailManager 实例
image.png
点开properties得知

@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
	private final User user = new User();
	public User getUser() {
		return this.user;
  }
  //....
	public static class User {
		private String name = "user";
		private String password = UUID.randomUUID().toString();
		private List<String> roles = new ArrayList<>();
		private boolean passwordGenerated = true;
		//get set ...
	}
}

这就是默认生成 user 以及 uuid 密码过程! 另外看明白源码之后,就知道只要在配置文件中加入如下配置可以对内存中用户和密码进行覆盖。

spring.security.user.name=root
spring.security.user.password=root
spring.security.user.roles=admin,users

六、自定义配置

1、认证配置

自定义认证配置
现在集成 security 之后,默认是把所有请求都进行拦截,并且需要认证。
现想把一些不重要的请求放开,需要继承 WebSecurityConfigurerAdapter 类,进行功能覆盖,不走默认配置。
比如:
/index/test :公共资源
/index/test2 :受保护资源

@RestController
@RequestMapping("index")
public class IndexController {

    @GetMapping("test")
    public String test(){
        return "Hello Security!";
    }

    @GetMapping("test2")
    public String test2(){
        return "byebye Security!";
    }
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter{

    //重写之后,便走这个配置认证路线
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
        //放开这个请求路径
        .mvcMatchers("/index/test").permitAll()
        //剩余的请求都要认证
        .anyRequest().authenticated()
        .and().formLogin();
    }
}

image.png
访问/index/test2 则会跳到登录页面
image.png

2、自定义登录界面

可以集成这个来当做练习

<!--thymeleaf-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
server.port=8800

# 设置security的账号跟密码
spring.security.user.name=root
spring.security.user.password=123

# thymeleaf的配置
spring.thymeleaf.cache=false
spring.thymeleaf.suffix=.html
spring.thymeleaf.prefix=classpath:/templates/
<!DOCTYPE html>
<html lang="en" xmlns:th="https://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>登录</title>
  </head>
  <body>
    <h1>用户登录</h1>
    <form method="post" th:action="@{/doLogin}">
      用户名:<input name="username" type="text"/><br>
      密码:<input name="password" type="password"/><br>
      <input type="submit" value="登录"/>
    </form>
  </body>
</html>
@Controller
public class LoginController {

    @RequestMapping(value = "/login.html")
    public String login() {
        return "login";
    }
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter{

    //重写之后,便走这个配置认证路线
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                //放开这个请求路径
                .mvcMatchers("/index/test").permitAll()
                .mvcMatchers("/login.html").permitAll()
                //剩余的请求都要认证
                .anyRequest().authenticated()
                .and()
                .formLogin()
                //用来指定默认登录页面,这里不是访问html,而是接口请求路径
                .loginPage("/login.html")
                //指定处理登录请求url(前端调用这个路径之后,便交给security处理)
                .loginProcessingUrl("/doLogin")
                .and()
                //这里先关闭 CSRF
                .csrf().disable();
    }
}

当我访问:http://localhost:8800/index/test2
就会弹出自定义的登录界面
image.png
输入账号与密码之后才会跳转到http://localhost:8800/index/test2
image.png

修改账号和密码的key

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter{

    //重写之后,便走这个配置认证路线
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
        //放开这个请求路径
        .mvcMatchers("/index/test").permitAll()
        .mvcMatchers("/login.html").permitAll()
        //剩余的请求都要认证
        .anyRequest().authenticated()
        .and()
        .formLogin()
        //用来指定默认登录页面,这里不是访问html,而是接口请求路径
        .loginPage("/login.html")
        //指定处理登录请求url(前端调用这个路径之后,便交给security处理)
        .loginProcessingUrl("/doLogin")
        //参数定义
        .usernameParameter("uname")
        .passwordParameter("passwd")
        .and()
        //这里先关闭 CSRF
        .csrf().disable();
    }
}
<form method="post" th:action="@{/doLogin}">
  用户名:<input name="uname" type="text"/><br>
  密码:<input name="passwd" type="password"/><br>
  <input type="submit" value="登录"/>
</form>

修改登录成功后执行的接口

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter{

    //重写之后,便走这个配置认证路线
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
        //放开这个请求路径
        .mvcMatchers("/index/test").permitAll()
        .mvcMatchers("/login.html").permitAll()
        //剩余的请求都要认证
        .anyRequest().authenticated()
        .and()
        .formLogin()
        //用来指定默认登录页面,这里不是访问html,而是接口请求路径
        .loginPage("/login.html")
        //指定处理登录请求url
        .loginProcessingUrl("/doLogin")
        //参数定义
        .usernameParameter("uname")
        .passwordParameter("passwd")
        //forward 跳转       注意:不会跳转到之前请求路径;
        //.successForwardUrl("/index/test2")
        //redirect 重定向    注意:如果之前请求路径,会有优先跳转之前请求路径
        .defaultSuccessUrl("/index/test2",true)
        //认证失败跳转页面
        .failureUrl("/login.html")
        // 失败以后的 forward 跳转
        //.failureForwardUrl("/login.html")
        .and()
        //这里先关闭 CSRF
        .csrf().disable();
    }
}

successForwardUrl 、defaultSuccessUrl 这两个方法都可以实现成功之后跳转

  • successForwardUrl 默认使用 forward跳转 注意:不会跳转到之前请求路径
  • defaultSuccessUrl 默认使用 redirect 跳转 注意:如果之前请求路径,会有优先跳转之前请求路径,可以传入第二个参数进行修改

failureUrl、failureForwardUrl 关系类似于之前提到的 successForwardUrl 、defaultSuccessUrl 方法

  • failureUrl 失败以后的重定向跳转
  • failureForwardUrl 失败以后的 forward 跳转 注意:因此获取 request 中异常信息,这里只能使用failureForwardUrl

3、自定义登录成功与失败处理

对于前后端分离开发中,我们登录成功之后是想返回一串json,通知前端是否登录成功。

成功处理

这时候可以通过自定义 AuthenticationSucccessHandler 实现

public interface AuthenticationSuccessHandler {

	/**
	 * Called when a user has been successfully authenticated.
	 * @param request the request which caused the successful authentication
	 * @param response the response
	 * @param authentication the <tt>Authentication</tt> object which was created during
	 * the authentication process.
	 */
	void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException;
}

根据接口的描述信息,也可以得知登录成功会自动回调这个方法,进一步查看它的默认实现,你会发现successForwardUrl、defaultSuccessUrl也是由它的子类实现的

自定义 AuthenticationSuccessHandler 实现

public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        Map<String, Object> result = new HashMap<String, Object>();
        result.put("msg", "登录成功");
        result.put("status", 200);
        response.setContentType("application/json;charset=UTF-8");
        String s = new ObjectMapper().writeValueAsString(result);
        response.getWriter().println(s);
    }
}

配置 AuthenticationSuccessHandler

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests()
                //...
                .and()
                .formLogin()
                //....
                .successHandler(new MyAuthenticationSuccessHandler())
                .failureUrl("/login.html")
                .and()
                .csrf().disable();//这里先关闭 CSRF
    }
}

image.png

失败处理

这时候可以通过自定义 AuthenticationFailureHandler 实现

public interface AuthenticationFailureHandler {

	/**
	 * Called when an authentication attempt fails.
	 * @param request the request during which the authentication attempt occurred.
	 * @param response the response.
	 * @param exception the exception which was thrown to reject the authentication
	 * request.
	 */
	void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException;

}

根据接口的描述信息,也可以得知登录失败会自动回调这个方法,进一步查看它的默认实现,你会发现failureUrl、failureForwardUrl也是由它的子类实现的。

自定义 AuthenticationFailureHandler 实现

public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        Map<String, Object> result = new HashMap<String, Object>();
        result.put("msg", "登录失败: "+exception.getMessage());
        result.put("status", 500);
        response.setContentType("application/json;charset=UTF-8");
        String s = new ObjectMapper().writeValueAsString(result);
        response.getWriter().println(s);
    }
}

配置 AuthenticationFailureHandler

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests()
	              //...
                .and()
                .formLogin()
               	//..
                .failureHandler(new MyAuthenticationFailureHandler())
                .and()
                .csrf().disable();//这里先关闭 CSRF
    }
}

image.png

4、注销登录

Spring Security 中也提供了默认的注销登录配置,在开发时也可以按照自己需求对注销进行个性化定制。
开启注销登录默认开启

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests()
                //...
                .and()
                .formLogin()
                //...
                .and()
                //注销
                .logout()
                //logoutUrl 指定退出登录请求地址,默认是 GET 请求,路径为 /logout
                //security去处理
                .logoutUrl("/logout")
                //session失效
                .invalidateHttpSession(true)
                //清空认证信息
                .clearAuthentication(true)
                //注销成功后跳转
                .logoutSuccessUrl("/login.html")
                .and()
                .csrf().disable();//这里先关闭 CSRF
    }
}

前后端分离
自定义 LogoutSuccessHandler

public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        Map<String, Object> result = new HashMap<String, Object>();
        result.put("msg", "注销成功");
        result.put("status", 200);
        response.setContentType("application/json;charset=UTF-8");
        String s = new ObjectMapper().writeValueAsString(result);
        response.getWriter().println(s);
    }
}

配置LogoutSuccessHandler

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests()
          		//....
                .and()
                .formLogin()
 				//...
                .and()
                .logout()
                .logoutUrl("/logout")
                .invalidateHttpSession(true)
                .clearAuthentication(true)
                .logoutSuccessHandler(new MyLogoutSuccessHandler())
                .and()
                .csrf().disable();//这里先关闭 CSRF
    }
}

image.png

5、登录用户数据获取

5.1、SecurityContextHolder

Spring Security 会将登录用户数据保存在 Session 中。但是,为了使用方便,Spring Security在此基础上还做了一些改进,其中最主要的一个变化就是线程绑定。当用户登录成功后,Spring Security 会将登录成功的用户信息保存到 SecurityContextHolder 中。
SecurityContextHolder 中的数据保存默认是通过ThreadLocal 来实现的,使用 ThreadLocal 创建的变量只能被当前线程访问,不能被其他线程访问和修改,也就是用户数据和请求线程绑定在一起。当登录请求处理完毕后,Spring Security 会将 SecurityContextHolder 中的数据拿出来保存到 Session 中,同时将 SecurityContexHolder 中的数据清空。以后每当有请求到来时,Spring Security 就会先从 Session 中取出用户登录数据,保存到SecurityContextHolder 中,方便在该请求的后续处理过程中使用,同时在请求结束时将 SecurityContextHolder 中的数据拿出来保存到 Session 中,然后将SecurityContextHolder 中的数据清空。
实际上 SecurityContextHolder 中存储是 SecurityContext,在 SecurityContext 中存储是 Authentication。
image.png
这种设计是典型的策略设计模式:

public class SecurityContextHolder {
	public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
	public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
	public static final String MODE_GLOBAL = "MODE_GLOBAL";
	private static final String MODE_PRE_INITIALIZED = "MODE_PRE_INITIALIZED";
	private static SecurityContextHolderStrategy strategy;
  //....
	private static void initializeStrategy() {
		if (MODE_PRE_INITIALIZED.equals(strategyName)) {
			Assert.state(strategy != null, "When using " + MODE_PRE_INITIALIZED
					+ ", setContextHolderStrategy must be called with the fully constructed strategy");
			return;
		}
		if (!StringUtils.hasText(strategyName)) {
			// Set default
			strategyName = MODE_THREADLOCAL;
		}
		if (strategyName.equals(MODE_THREADLOCAL)) {
			strategy = new ThreadLocalSecurityContextHolderStrategy();
			return;
		}
		if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
			strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
			return;
		}
		if (strategyName.equals(MODE_GLOBAL)) {
			strategy = new GlobalSecurityContextHolderStrategy();
			return;
		}
    //.....
  }
}
  1. MODE THREADLOCAL:这种存放策略是将 SecurityContext 存放在 ThreadLocal中,大家知道 Threadlocal 的特点是在哪个线程中存储就要在哪个线程中读取,这其实非常适合 web 应用,因为在默认情况下,一个请求无论经过多少 Filter 到达 Servlet,都是由一个线程来处理的。这也是 SecurityContextHolder 的默认存储策略,这种存储策略意味着如果在具体的业务处理代码中,开启了子线程,在子线程中去获取登录用户数据,就会获取不到。
  2. MODE INHERITABLETHREADLOCAL:这种存储模式适用于多线程环境,如果希望在子线程中也能够获取到登录用户数据,那么可以使用这种存储模式。
  3. MODE GLOBAL:这种存储模式实际上是将数据保存在一个静态变量中,在 JavaWeb开发中,这种模式很少使用到。
5.2、SecurityContextHolderStrategy

通过 SecurityContextHolder 可以得知,SecurityContextHolderStrategy 接口用来定义存储策略方法

public interface SecurityContextHolderStrategy {
    //该方法用来清除存储的 SecurityContext对象。
	void clearContext();
    //该方法用来获取存储的 SecurityContext 对象。
	SecurityContext getContext();
    //setContext:该方法用来设置存储的 SecurityContext 对象。
	void setContext(SecurityContext context);
    //create Empty Context:该方法则用来创建一个空的 SecurityContext 对象。
	SecurityContext createEmptyContext();
}

每个实现类对应一个策略的实现
image.png

@RestController
public class UserController {

    @GetMapping("user")
    public String getUser(){
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        User principal = (User) authentication.getPrincipal();
        System.out.println("身份 :"+principal.getUsername());
        System.out.println("凭证 :"+authentication.getCredentials());
        System.out.println("权限 :"+authentication.getAuthorities());
        return "看控制台!";
    }
}

image.png

6、自定义认证数据源

6.1、原理

认证流程如下:
image.png

  • 发起认证请求,请求中携带用户名、密码,该请求会被UsernamePasswordAuthenticationFilter 拦截
  • UsernamePasswordAuthenticationFilterattemptAuthentication方法中将请求中用户名和密码,封装为Authentication对象,并交给AuthenticationManager 进行认证
  • 认证成功,将认证信息存储到 SecurityContextHodler 以及调用记住我等,并回调 AuthenticationSuccessHandler 处理
  • 认证失败,清除 SecurityContextHodler 以及 记住我中信息,回调 AuthenticationFailureHandler 处理

AuthenticationManager 与 ProviderManager 和 AuthenticationProvider 的关系

  • AuthenticationManager 是一个认证管理器,它定义了 Spring Security 过滤器要执行认证操作。
  • ProviderManager AuthenticationManager接口的实现类。Spring Security 认证时默认使用就是 ProviderManager。
  • AuthenticationProvider 就是针对不同的身份类型执行的具体的身份认证。

ProviderManager 是 AuthenticationManager 的唯一实现,也是 Spring Security 默认使用实现。从这里不难看出默认情况下AuthenticationManager 就是一个ProviderManager。

弄清楚认证原理之后我们来看下具体认证时数据源的获取。默认情况下 AuthenticationProvider 是由 DaoAuthenticationProvider 类来实现认证的,在DaoAuthenticationProvider 认证时又通过 UserDetailsService 完成数据源的校验。他们之间调用关系如下:
未命名文件.jpg

总结: AuthenticationManager 是认证管理器,在 Spring Security 中有全局AuthenticationManager,也可以有局部AuthenticationManager。全局的AuthenticationManager用来对全局认证进行处理,局部的AuthenticationManager用来对某些特殊资源认证处理。当然无论是全局认证管理器还是局部认证管理器都是由 ProviderManger 进行实现。 每一个ProviderManger中都代理一个AuthenticationProvider的列表,列表中每一个实现代表一种身份认证方式。认证时底层数据源需要调用 UserDetailService 来实现。

6.2、配置

配置全局的AuthenticationManager

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
  @Autowired
  public void initialize(AuthenticationManagerBuilder builder) {
    //builder..
  }
}

springboot 对 security 进行自动配置时自动在工厂中创建一个全局AuthenticationManager(默认)

举例:

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
 //全局AuthenticationManager配置
 @Autowired
 public void initialize(AuthenticationManagerBuilder builder) throws Exception {
    //基于内存的数据源
    InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
    //用户名:admin 密码:123 替换掉properties文件的配置
    userDetailsManager.createUser(User.withUsername("admin").password("{noop}123").roles("admin").build());
    builder.userDetailsService(userDetailsManager);
 }
}

亦或者:
UserDetailsServiceAutoConfiguration 配置类中

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(AuthenticationManager.class)
@ConditionalOnBean(ObjectPostProcessor.class)
@ConditionalOnMissingBean(
		value = { AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class },
		type = { "org.springframework.security.oauth2.jwt.JwtDecoder",
				"org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector" })
public class UserDetailsServiceAutoConfiguration {

	private static final String NOOP_PASSWORD_PREFIX = "{noop}";

	private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");

	private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);

	@Bean
	@ConditionalOnMissingBean(
			type = "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository")
	@Lazy
	public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
			ObjectProvider<PasswordEncoder> passwordEncoder) {
		SecurityProperties.User user = properties.getUser();
		List<String> roles = user.getRoles();
		return new InMemoryUserDetailsManager(
				User.withUsername(user.getName()).password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
						.roles(StringUtils.toStringArray(roles)).build());
	}
}

假如springboot检测到工厂里有 userDetailsService 的Bean,则下面的数据源bean不会生效,从而使用你创建的userDetailsService 的Bean

总结

  1. 默认自动配置创建全局AuthenticationManager 默认找当前项目中是否存在自定义 UserDetailService 实例 自动将当前项目 UserDetailService 实例设置为数据源
  2. 默认自动配置创建全局AuthenticationManager 在工厂中使用时直接在代码中注入即可

自定义全局 AuthenticationManager

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
  @Override
  public void configure(AuthenticationManagerBuilder builder) {
  	//builder ....
  }
}

自定义全局 AuthenticationManager

举例:

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {

  //自定义全局 AuthenticationManager  会覆盖掉默认的全局 AuthenticationManager
  //推荐这种
  @Override
  public void configure(AuthenticationManagerBuilder builder) {
  	//基于内存的数据源
    InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
    //用户名:admin 密码:123 替换掉properties文件的配置
    userDetailsManager.createUser(User.withUsername("admin").password("{noop}123").roles("admin").build());
    builder.userDetailsService(userDetailsManager);
  }
}

总结

  1. 一旦通过 configure 方法自定义 AuthenticationManager实现 就回将工厂中自动配置AuthenticationManager 进行覆盖
  2. 一旦通过 configure 方法自定义 AuthenticationManager实现 需要在实现中指定认证数据源对象 UserDetaiService 实例
  3. 一旦通过 configure 方法自定义 AuthenticationManager实现 这种方式创建AuthenticationManager对象工厂内部本地一个 AuthenticationManager 对象 不允许在其他自定义组件中进行注入
@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {

  //自定义全局 AuthenticationManager  会覆盖掉默认的全局 AuthenticationManager
  //推荐这种
  @Override
  public void configure(AuthenticationManagerBuilder builder) {
  	//基于内存的数据源
    InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
    //用户名:admin 密码:123 替换掉properties文件的配置
    userDetailsManager.createUser(User.withUsername("admin").password("{noop}123").roles("admin").build());
    builder.userDetailsService(userDetailsManager);
  }

    //作用: 用来将自定义AuthenticationManager在工厂中进行暴露,可以在任何位置注入
	//不加这个,就不能在业务类上注入 AuthenticationManager
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}
6.3、数据库数据源配置

1、表设计

-- 用户表
CREATE TABLE `user`
(
  `id`                    int(11) NOT NULL AUTO_INCREMENT,
  `username`              varchar(32)  DEFAULT NULL,
  `password`              varchar(255) DEFAULT NULL,
  `enabled`               tinyint(1) DEFAULT NULL,
  `accountNonExpired`     tinyint(1) DEFAULT NULL,
  `accountNonLocked`      tinyint(1) DEFAULT NULL,
  `credentialsNonExpired` tinyint(1) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
-- 角色表
CREATE TABLE `role`
(
  `id`      int(11) NOT NULL AUTO_INCREMENT,
  `name`    varchar(32) DEFAULT NULL,
  `name_zh` varchar(32) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
-- 用户角色关系表
CREATE TABLE `user_role`
(
  `id`  int(11) NOT NULL AUTO_INCREMENT,
  `uid` int(11) DEFAULT NULL,
  `rid` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY   `uid` (`uid`),
  KEY   `rid` (`rid`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;

2、插入测试数据

-- 插入用户数据
BEGIN;
  INSERT INTO `user`
  VALUES (1, 'root', '{noop}123', 1, 1, 1, 1);
  INSERT INTO `user`
  VALUES (2, 'admin', '{noop}123', 1, 1, 1, 1);
  INSERT INTO `user`
  VALUES (3, 'blr', '{noop}123', 1, 1, 1, 1);
COMMIT;
-- 插入角色数据
BEGIN;
  INSERT INTO `role`
  VALUES (1, 'ROLE_product', '商品管理员');
  INSERT INTO `role`
  VALUES (2, 'ROLE_admin', '系统管理员');
  INSERT INTO `role`
  VALUES (3, 'ROLE_user', '用户管理员');
COMMIT;
-- 插入用户角色数据
BEGIN;
  INSERT INTO `user_role`
  VALUES (1, 1, 1);
  INSERT INTO `user_role`
  VALUES (2, 1, 2);
  INSERT INTO `user_role`
  VALUES (3, 2, 2);
  INSERT INTO `user_role`
  VALUES (4, 3, 3);
COMMIT;

3、引入依赖

    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.2.0</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>5.1.49</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>1.2.6</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>

4、配置文件

# datasource
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://ip:3306/study?characterEncoding=UTF-8&useSSL=false
spring.datasource.username=xxx
spring.datasource.password=xxx

# mybatis
mybatis.mapper-locations=classpath:com/jun/mapper/*.xml

# log
logging.level.com.jun=debug

5、User对象

@Data
public class User  implements UserDetails {
    private Integer id;
    private String username;
    private String password;
    private Boolean enabled;
    private Boolean accountNonExpired;
    private Boolean accountNonLocked;
    private Boolean credentialsNonExpired;
    private List<Role> roles = new ArrayList<>();

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        roles.forEach(role->grantedAuthorities.add(new SimpleGrantedAuthority(role.getName())));
        return grantedAuthorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

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

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

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

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

}

6、Role对象

@Data
public class Role {
    private Integer id;
    private String name;
    private String nameZh;
}

7、设置UserMapper

@Mapper
public interface UserMapper {
    //根据用户名查询用户
    User loadUserByUsername(String username);
  	
  	//根据用户id查询角色
  	List<Role> getRolesByUid(Integer uid);
}
<?xml version="1.0" encoding="UTF8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jun.mapper.UserMapper">
  <!--查询单个-->
  <select id="loadUserByUsername" resultType="com.jun.entity.User">
    select id,
    username,
    password,
    enabled,
    accountNonExpired,
    accountNonLocked,
    credentialsNonExpired
    from user
    where username = #{username}
  </select>

  <!--查询指定行数据-->
  <select id="getRolesByUid" resultType="com.jun.entity.Role">
    select r.id,
    r.name,
    r.name_zh nameZh
    from role r,
    user_role ur
    where r.id = ur.rid
    and ur.uid = #{uid}
  </select>
</mapper>

8、创建UserDetailsService实例

@Service
@AllArgsConstructor
public class MyUserDetailService implements UserDetailsService {

    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMapper.loadUserByUsername(username);
        if(ObjectUtils.isEmpty(user))throw new RuntimeException("用户不存在");
        user.setRoles(userMapper.getRolesByUid(user.getId()));
        return user;
    }
}

9、配置类中自定义配置全局AuthenticationManager

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter{

    //假如springboot检测到工厂里有 userDetailsService 的Bean,则下面的数据源bean不会生效。
    //从而使用你创建的userDetailsService 的Bean
    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(AuthenticationManagerBuilder builder) throws Exception {
        builder.userDetailsService(userDetailsService);
    }

	。。。
}

7、验证码

前后端分离写法

<dependency>
  <groupId>com.github.penggle</groupId>
  <artifactId>kaptcha</artifactId>
  <version>2.3.2</version>
</dependency>
@Configuration
public class KaptchaConfig {
    @Bean
    public Producer kaptcha() {
        Properties properties = new Properties();
        properties.setProperty("kaptcha.image.width", "150");
        properties.setProperty("kaptcha.image.height", "50");
        properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
        properties.setProperty("kaptcha.textproducer.char.length", "4");
        Config config = new Config(properties);
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }
}
@RestController
@AllArgsConstructor
public class KaptchaController {

    private Producer producer;

    @GetMapping("/image")
    public String getVerifyCode(HttpSession session) throws IOException {
        //1.生成验证码
        String code = producer.createText();
        session.setAttribute("kaptcha", code);//可以更换成 redis 实现
        BufferedImage bi = producer.createImage(code);
        //2.写入内存
        FastByteArrayOutputStream fos = new FastByteArrayOutputStream();
        ImageIO.write(bi, "png", fos);
        //3.生成 base64
        return Base64.encodeBase64String(fos.toByteArray());
    }
}

写个异常类

public class KaptchaNotMatchException extends AuthenticationException {

    public KaptchaNotMatchException(String msg) {
        super(msg);
    }

    public KaptchaNotMatchException(String msg, Throwable cause) {
        super(msg, cause);
    }
}
//继承UsernamePasswordAuthenticationFilter类重写attemptAuthentication方法
@Data
public class LoginKaptchaFilter extends UsernamePasswordAuthenticationFilter {

    public static final String FORM_KAPTCHA_KEY = "kaptcha";

    private String kaptchaParameter = FORM_KAPTCHA_KEY;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        try {
            //1.获取请求数据
            Map<String, String> userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
            String kaptcha = userInfo.get(getKaptchaParameter());//用来获取数据中验证码
            String username = userInfo.get(getUsernameParameter());//用来接收用户名
            String password = userInfo.get(getPasswordParameter());//用来接收密码
            //2.获取 session 中验证码
            String sessionVerifyCode = (String) request.getSession().getAttribute("kaptcha");

            //跟源码保持一致
            if (!ObjectUtils.isEmpty(kaptcha) && !ObjectUtils.isEmpty(sessionVerifyCode) &&
                    kaptcha.equalsIgnoreCase(sessionVerifyCode)) {
                //3.获取用户名 和密码认证
                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
                setDetails(request, authRequest);
                return this.getAuthenticationManager().authenticate(authRequest);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        throw new KaptchaNotMatchException("验证码不匹配!");
    }
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter{

    @Autowired
    private UserDetailsService userDetailsService;

    //代替了 UsernamePasswordAuthenticationFilter 过滤器之后,需要配置认证的基本配置
    @Bean
    public LoginKaptchaFilter loginKaptchaFilter() throws Exception {
        LoginKaptchaFilter loginKaptchaFilter = new LoginKaptchaFilter();
        //1.认证 url
        loginKaptchaFilter.setFilterProcessesUrl("/doLogin");
        //2.认证 接收参数
        loginKaptchaFilter.setUsernameParameter("uname");
        loginKaptchaFilter.setPasswordParameter("passwd");
        loginKaptchaFilter.setKaptchaParameter("kaptcha");
        //3.指定认证管理器
        loginKaptchaFilter.setAuthenticationManager(authenticationManagerBean());
        //4.指定成功时处理
        loginKaptchaFilter.setAuthenticationSuccessHandler(new MyAuthenticationSuccessHandler());
        //5.认证失败处理
        loginKaptchaFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler());
        return loginKaptchaFilter;
    }

    

    @Override
    protected void configure(AuthenticationManagerBuilder builder) throws Exception {
        builder.userDetailsService(userDetailsService);
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/image").permitAll()
                .mvcMatchers("/index/test").permitAll()
                .mvcMatchers("/login.html").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                //认证抛异常处理
                .exceptionHandling()
                //可创个类去实现该接口
                .authenticationEntryPoint((req, resp, ex) -> {
                    resp.setContentType("application/json;charset=UTF-8");
                    resp.setStatus(HttpStatus.UNAUTHORIZED.value());
                    resp.getWriter().println("必须认证之后才能访问!");
                })
                .and()
                .logout()
                .and()
                .csrf().disable();

        //将自定义的 loginKaptchaFilter 代替了 UsernamePasswordAuthenticationFilter过滤器
        http.addFilterAt(loginKaptchaFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

七、密码加密

在Spring Security 中,我们现在是用一种自适应单向函数 (Adaptive One-way Functions)来处理密码问题,这种自适应单向函数在进行密码匹配时,会有意占用大量系统资源(例如CPU、内存等),这样可以增加恶意用户攻击系统的难度。在Spring Securiy 中,开发者可以通过** bcrypt、PBKDF2、sCrypt 以及 argon2** 来体验这种自适应单向函数加密。由于自适应单向函数有意占用大量系统资源,因此每个登录认证请求都会大大降低应用程序的性能,但是 Spring Secuity 不会采取任何措施来提高密码验证速度,因为它正是通过这种方式来增强系统的安全性。

通过对认证流程源码分析得知,实际密码比较是由PasswordEncoder完成的,因此只需要使用PasswordEncoder 不同实现就可以实现不同方式加密。
PasswordEncoder 源码
image.png

  • encode 用来进行明文加密的
  • matches 用来比较密码的方法
  • upgradeEncoding 用来给密码进行升级的方法

image.png

DelegatingPasswordEncoder
根据上面 PasswordEncoder的介绍,可能会以为 Spring security 中默认的密码加密方案应该是四种自适应单向加密函数中的一种,其实不然,在 spring Security 5.0之后,默认的密码加密方案其实是 DelegatingPasswordEncoder。从名字上来看,DelegatingPaswordEncoder 是一个代理类,而并非一种全新的密码加密方案,DeleggtinePasswordEncoder 主要用来代理上面介绍的不同的密码加密方案。为什么采DelegatingPasswordEncoder 而不是某一个具体加密方式作为默认的密码加密方案呢?主要考虑了如下两方面的因素:

  • 兼容性:使用 DelegatingPasswrordEncoder 可以帮助许多使用旧密码加密方式的系统顺利迁移到 Spring security 中,它允许在同一个系统中同时存在多种不同的密码加密方案。
  • 便捷性:密码存储的最佳方案不可能一直不变,如果使用 DelegatingPasswordEncoder作为默认的密码加密方案,当需要修改加密方案时,只需要修改很小一部分代码就可以实现。
public class DelegatingPasswordEncoder implements PasswordEncoder {
  ....
}

PasswordEncoderFactories 工厂类里有各种加密方式

public final class PasswordEncoderFactories {

	private PasswordEncoderFactories() {
	}

	@SuppressWarnings("deprecation")
	public static PasswordEncoder createDelegatingPasswordEncoder() {
		String encodingId = "bcrypt";
		Map<String, PasswordEncoder> encoders = new HashMap<>();
		encoders.put(encodingId, new BCryptPasswordEncoder());
		encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
		encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
		encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
		encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
		encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
		encoders.put("scrypt", new SCryptPasswordEncoder());
		encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
		encoders.put("SHA-256",
				new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
		encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
		encoders.put("argon2", new Argon2PasswordEncoder());
		return new DelegatingPasswordEncoder(encodingId, encoders);
	}

}

PasswordEncoder源码走读

1、点击 formLogin 方法
2、点击 UsernamePasswordAuthenticationFilter 类的 attemptAuthentication方法

public FormLoginConfigurer() {
		super(new UsernamePasswordAuthenticationFilter(), null);
		usernameParameter("username");
		passwordParameter("password");
	}

3、点击 authenticate 方法

@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
		if (this.postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
		}
		String username = obtainUsername(request);
		username = (username != null) ? username : "";
		username = username.trim();
		String password = obtainPassword(request);
		password = (password != null) ? password : "";
		UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}

4、点击 authenticate 方法
image.png
5、点击 additionalAuthenticationChecks 方法
image.png
6、进入DaoAuthenticationProvider类的additionalAuthenticationChecks方法

@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
        UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    if (authentication.getCredentials() == null) {
        this.logger.debug("Failed to authenticate since no credentials provided");
        throw new BadCredentialsException(this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
    }
    //获取浏览器传来的明文密码
    String presentedPassword = authentication.getCredentials().toString();
    //密码比对 presentedPassword 前端的明文密码,userDetails.getPassword() 数据库存的密码
    if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
        this.logger.debug("Failed to authenticate since password does not match stored value");
        throw new BadCredentialsException(this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
    }
}

7、进入 DelegatingPasswordEncoder 的 matches 方法

@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
    if (rawPassword == null && prefixEncodedPassword == null) {
        return true;
    }
    //获取数据库密码的前缀,比如{scrypt}
    String id = extractId(prefixEncodedPassword);
    // idToPasswordEncoder 在 PasswordEncoderFactories 工厂已经填入数据
    // 策略模式根据 前缀 去找 对应的PasswordEncoder的子类实现
    PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
    if (delegate == null) {
        return this.defaultPasswordEncoderForMatches.matches(rawPassword, prefixEncodedPassword);
    }
    String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
    return delegate.matches(rawPassword, encodedPassword);
}

从而实现密码匹配

我们应如何使用 PasswordEncoder

首先,先查看WebSecurityConfigurerAdapter类中源码

static class LazyPasswordEncoder implements PasswordEncoder {
		private ApplicationContext applicationContext;
		private PasswordEncoder passwordEncoder;
		LazyPasswordEncoder(ApplicationContext applicationContext) {
			this.applicationContext = applicationContext;
		}
		@Override
		public String encode(CharSequence rawPassword) {
			return getPasswordEncoder().encode(rawPassword);
		}

		@Override
		public boolean matches(CharSequence rawPassword, String encodedPassword) {
			return getPasswordEncoder().matches(rawPassword, encodedPassword);
		}

		@Override
		public boolean upgradeEncoding(String encodedPassword) {
			return getPasswordEncoder().upgradeEncoding(encodedPassword);
		}

		private PasswordEncoder getPasswordEncoder() {
			if (this.passwordEncoder != null) {
				return this.passwordEncoder;
			}
            //如果在工厂中指定了PasswordEncoder,则就会使用指定的PasswordEncoder
            //否则默认使用DelegatingPasswordEncoder
			PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class);
			if (passwordEncoder == null) {
				passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
			}
			this.passwordEncoder = passwordEncoder;
			return passwordEncoder;
		}

		private <T> T getBeanOrNull(Class<T> type) {
			try {
				return this.applicationContext.getBean(type);
			}
			catch (NoSuchBeanDefinitionException ex) {
				return null;
			}
		}

		@Override
		public String toString() {
			return getPasswordEncoder().toString();
		}

	}

通过源码分析得知如果在工厂中指定了PasswordEncoder,就会使用指定PasswordEncoder,否则就会使用默认DelegatingPasswordEncoder。

  1. 使用固定密码加密方案
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
     //注入使用
     @Bean
     public PasswordEncoder BcryptPasswordEncoder() {
         return new BCryptPasswordEncoder();
     }
  	 @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        inMemoryUserDetailsManager.createUser(User.withUsername("root").password("$2a$10$WGFkRsZC0kzafTKOPcWONeLvNvg2jqd3U09qd5gjJGSHE5b0yoy6a").roles("xxx").build());
        return inMemoryUserDetailsManager;
    }
}
  1. 使用灵活密码加密方案 推荐
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  	 @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        //在密码前面加个{加密类型}
        inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{bcrypt}$2a$10$WGFkRsZC0kzafTKOPcWONeLvNvg2jqd3U09qd5gjJGSHE5b0yoy6a").roles("xxx").build());
        return inMemoryUserDetailsManager;
    }
}

八、RememberMe

RememberMe这个并不是把用户名/密码用 Cookie 保存在浏览器中,下次登录时不用再次输入用户名/密码。 而是一种服务器端的行为。传统的登录方式基于 Session会话,一旦用户的会话超时过期,就要再次登录,这样太过于烦琐。如果能有一种机制,让用户会话过期之后,还能继续保持认证状态,就会方便很多,RememberMe 就是为了解决这一需求而生的。
image.png

1、基本使用

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    //....
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/login.html").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                //...
                .and()
                .rememberMe() //开启记住我功能
                .and()
                .csrf().disable();
    }
}

image.png
测试:
登录时勾选 RememberMe 选项,然后重启服务端之后,在测试接口是否能免登录访问。

从上图中,当在SecurityConfig配置中开启了"记住我"功能之后,在进行认证时如果勾选了"记住我"选项,此时打开浏览器控制台,分析整个登录过程。首先当我们登录时,在登录请求中多了一个 RememberMe 的参数。
image.png
很显然,这个参数就是告诉服务器应该开启 RememberMe功能的。如果自定义登录页面开启 RememberMe 功能应该多加入一个一样的请求参数就可以啦。该请求会被RememberMeAuthenticationFilter 进行拦截然后自动登录具体参见源码:

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    //请求到达过滤器之后,首先判断 SecurityContextHolder 中是否有值
    if (SecurityContextHolder.getContext().getAuthentication() != null) {
        this.logger.debug(LogMessage
                .of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '"
                        + SecurityContextHolder.getContext().getAuthentication() + "'"));
        chain.doFilter(request, response);
        return;
    }
    //没值的话表示用户尚未登录,此时调用 autoLogin 方法进行自动登录。
    Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
    //当自动登录成功后返回的rememberMeAuth 不为null 时,表示自动登录成功
    if (rememberMeAuth != null) {
        try {
            //此时调用 authenticate 方法对 key 进行校验,并且将登录成功的用户信息保存到 SecurityContextHolder 对象中,然后调用登录成功回调,并发布登录成功事件。
    		//需要注意的是,登录成功的回调并不包含 RememberMeServices 中的 loginSuccess 方法。
            rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
            SecurityContext context = SecurityContextHolder.createEmptyContext();
            context.setAuthentication(rememberMeAuth);
            SecurityContextHolder.setContext(context);
            onSuccessfulAuthentication(request, response, rememberMeAuth);
            this.logger.debug(LogMessage.of(() -> "SecurityContextHolder populated with remember-me token: '"
                    + SecurityContextHolder.getContext().getAuthentication() + "'"));
            this.securityContextRepository.saveContext(context, request, response);
            if (this.eventPublisher != null) {
                this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
                        SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
            }
            if (this.successHandler != null) {
                this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
                return;
            }
        }
        catch (AuthenticationException ex) {
            this.logger.debug(LogMessage
                    .format("SecurityContextHolder not populated with remember-me token, as AuthenticationManager "
                            + "rejected Authentication returned by RememberMeServices: '%s'; "
                            + "invalidating remember-me token", rememberMeAuth),
                    ex);
            //如果自动登录失败,则调用 remenberMeServices.loginFail方法处理登录失败回调。
            this.rememberMeServices.loginFail(request, response);
            onUnsuccessfulAuthentication(request, response, ex);
        }
    }
    chain.doFilter(request, response);
}
  • (1)请求到达过滤器之后,首先判断 SecurityContextHolder 中是否有值,没值的话表示用户尚未登录,此时调用 autoLogin 方法进行自动登录。

  • (2)当自动登录成功后返回的rememberMeAuth 不为null 时,表示自动登录成功,此时调用 authenticate 方法对 key 进行校验,并且将登录成功的用户信息保存到 SecurityContextHolder 对象中,然后调用登录成功回调,并发布登录成功事件。需要注意的是,登录成功的回调并不包含 RememberMeServices 中的 loginSuccess 方法。

  • (3)如果自动登录失败,则调用 remenberMeServices.loginFail方法处理登录失败回调。onUnsuccessfulAuthentication 和 onSuccessfulAuthentication 都是该过滤器中定义的空方法,并没有任何实现这就是 RememberMeAuthenticationFilter 过滤器所做的事情,成功将 RememberMeServices的服务集成进来。

九、会话管理

1、概念

当浏览器调用登录接口登录成功后,服务端会和浏览器之间建立一个会话 (Session) 浏览器在每次发送请求时都会携带一个 Sessionld,服务端则根据这个 Sessionld 来判断用户身份。当浏览器关闭后,服务端的 Session 并不会自动销毁,需要开发者手动在服务端调用 Session销毁方法,或者等 Session 过期时间到了自动销毁。在Spring Security 中,与HttpSession相关的功能由 SessionManagementFiter 和SessionAutheaticationStrateey 接口来处理,SessionManagomentFilter 过滤器将 Session 相关操作委托给 SessionAuthenticationStratery 接口去完成。

会话并发管理就是指在当前系统中,同一个用户可以同时创建多少个会话,如果一个设备对应一个会话,那么也可以简单理解为同一个用户可以同时在多少台设备上进行登录(多个人使用同一个账号)。默认情况下,同一用户在多少台设备上登录并没有限制,不过开发者可以在 Spring Security 中对此进行配置。

2、开启会话管理

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

  	//...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .rememberMe()
                .and()
                .csrf().disable()
                .sessionManagement()  //开启会话管理
                .maximumSessions(1);  //设置会话并发数为 1
    }

    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }
}
  1. sessionManagement() 用来开启会话管理、maximumSessions 指定会话的并发数为 1。
  2. HttpSessionEventPublisher 提供一一个Http SessionEvenePublishor-实例。Spring Security中通过一个 Map 集合来集护当前的 Http Session 记录,进而实现会话的并发管理。当用户登录成功时,就向集合中添加一条Http Session 记录;当会话销毁时,就从集合中移除一条 Httpsession 记录。HttpSesionEvenPublisher 实现了 Fttp SessionListener 接口,可以监听到 HttpSession 的创建和销毀事件,并将 Fltp Session 的创建/销毁事件发布出去,这样,当有 HttpSession 销毀时,Spring Security 就可以感知到该事件了。

3、会话失效

@Override
protected void configure(HttpSecurity http) throws Exception {
  http.authorizeRequests()
    .anyRequest().authenticated()
    .....
    .sessionManagement()  //开启会话管理
    .maximumSessions(1)  //允许同一个用户只允许创建一个会话
    .expiredSessionStrategy(event -> {
      HttpServletResponse response = event.getResponse();
      response.setContentType("application/json;charset=UTF-8");
      Map<String, Object> result = new HashMap<>();
      result.put("status", 500);
      result.put("msg", "当前会话已经失效,请重新登录!");
      String s = new ObjectMapper().writeValueAsString(result);
      response.setContentType("application/json;charset=UTF-8");
      response.getWriter().println(s);
      response.flushBuffer();
    });//前后端分离开发处理
}

4、禁止再次登录

默认的效果是一种被 “挤下线”的效果,后面登录的用户会把前面登录的用户 “挤下线”。还有一种是禁止后来者登录,即一旦当前用户登录成功,后来者无法再次使用相同的用户登录,直到当前用户主动注销登录,配置如下:

@Override
protected void configure(HttpSecurity http) throws Exception {
  http.authorizeRequests()
    .anyRequest().authenticated()
    .and()
    ....
    .sessionManagement()  //开启会话管理
    .maximumSessions(1)  //允许同一个用户只允许创建一个会话
    //.expiredUrl("/login")//会话过期处理  传统 web 开发
    .expiredSessionStrategy(event -> {
      HttpServletResponse response = event.getResponse();
      response.setContentType("application/json;charset=UTF-8");
      Map<String, Object> result = new HashMap<>();
      result.put("status", 500);
      result.put("msg", "当前会话已经失效,请重新登录!");
      String s = new ObjectMapper().writeValueAsString(result);
      response.getWriter().println(s);
      response.flushBuffer();
    })//前后端分离开发处理
    .maxSessionsPreventsLogin(true);//登录之后禁止再次登录
}

5、会话共享

前面所讲的会话管理都是单机上的会话管理,如果当前是集群环境,前面所讲的会话管理方案就会失效。此时可以利用 spring-session 结合 redis 实现 session 共享。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
</dependency>
spring.redis.host=localhost
spring.redis.port=6379
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {


    private final FindByIndexNameSessionRepository sessionRepository;


    @Autowired
    public SecurityConfig(FindByIndexNameSessionRepository sessionRepository) {
        this.sessionRepository = sessionRepository;
    }

    @Bean
    public UserDetailsService userDetailsService() {
        ....
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
        .anyRequest().authenticated()
        .and()
        .formLogin()
        .and()
        .rememberMe()
        .and()
        .csrf().disable()
        .sessionManagement()  //开启会话管理
        .maximumSessions(1)  //允许同一个用户只允许创建一个会话*/
        .expiredUrl("/login")//会话过期处理  传统 web 开发
        .expiredSessionStrategy(event -> {
            HttpServletResponse response = event.getResponse();
            response.setContentType("application/json;charset=UTF-8");
            Map<String, Object> result = new HashMap<>();
            result.put("status", 500);
            result.put("msg", "当前会话已经失效,请重新登录!");
            String s = new ObjectMapper().writeValueAsString(result);
            response.getWriter().println(s);
            response.flushBuffer();
        }).sessionRegistry(sessionRegistry());//前后端分离开发处理
        //.maxSessionsPreventsLogin(true);//登录之后禁止再次登录*/
    }

    @Bean
    public SpringSessionBackedSessionRegistry sessionRegistry() {
        return new SpringSessionBackedSessionRegistry(sessionRepository);
    }

}

十、跨域

1、CORS

CORS (Cross-Origin Resource Sharing )是由 W3C制定的一种跨域资源共享技术标准,其目的就是为了解决前端的跨域请求。在JavaEE 开发中,最常见的前端跨域请求解决方案是早期的JSONP,但是 JSONP 只支持 GET 请求,这是一个很大的缺陷,而 CORS 则支特多种 HTTP请求方法,也是目前主流的跨域解决方案。
CORS 中新增了一组HTTP 请求头字段,通过这些字段,服务器告诉浏览器,那些网站通过浏览器有权限访问哪些资源。同时规定,对那些可能修改服务器数据的HTTP请求方法 (如GET以外的HTTP 请求等),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(prenightst),预检请求的目的是查看服务端是否支持即将发起的跨域请求,如果服务端允许,才发送实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(如 Cookies、HTTP 认证信息等)。

CORS: 同源/同域 = 协议+主机+端口

简单请求
GET 请求为例,如果需要发起一个跨域请求,则请求头如下:

Host: localhost:8080
Origin: http://localhost:8081
Referer:http://localhost:8081/index.html

如果服务端支持该跨域请求,那么返回的响应头中将包含如下字段:

Access-Control-Allow-Origin:http://localhost: 8081

Access-Control-Allow-Origin 字段用来告诉浏览器可以访问该资源的域,当浏览器收到这样的响应头信息之后,提取出 Access-Control-Allow-Origin 字段中的值, 发现该值包含当前页面所在的域,就知道这个跨域是被允许的,因此就不再对前端的跨域请求进行限制。这属于简单请求,即不需要进行预检请求的跨域。

非简单请求
对于一些非简单请求,会首先发送一个预检请求。预检请求类似下面这样:

OPTIONS /put HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Accept: */*
Access-Control-Request-Method:PUT
Origin: http://localhost: 8081
Referer:http://localhost:8081/index.html

请求方法是 OPTIONS,请求头Origin 就告诉服务端当前页面所在域,请求头 Access-Control-Request-Methods 告诉服务器端即将发起的跨域请求所使用的万法。服务端对此进行判断,如果允许即将发起的跨域请求,则会给出如下响应:

HTTP/1.1 200
Access-Control-Allow-Origin:http://localhost: 8081
Access-Control-Request-Methods: PUT
Access-Control-Max-Age: 3600

Access-Control-Allow-Metbods 字段表示允许的跨域方法:Access-Control-Max-Age 字段表示预检请求的有效期,单位为秒,在有效期内如果发起该跨域请求,则不用再次发起预检请求。预检请求结朿后,接下来就会发起一个真正的跨域请求,跨域请求和前面的简单请求跨域步骤类似。

2、Spring跨域解决方案

@CrossOrigin
Spring 中第一种处理跨域的方式是通过@CrossOrigin 注解来标记支持跨域,该注解可以添加在方法上,也可以添加在 Controller 上。当添加在 Controller 上时,表示 Controller 中的所有接口都支持跨域,具体配置如下:

@RestController
public Class HelloController{
    @CrossOrigin (origins ="http://localhost:8081")
    @PostMapping ("/post")
    public String post (){
        return "hello post";
    }
}

@CrossOrigin 注解各属性含义如下:

  • allowCredentials:浏览器是否应当发送凭证信息,如 Cookie。
  • allowedHeaders: 请求被允许的请求头字段,*表示所有字段。
  • exposedHeaders:哪些响应头可以作为响应的一部分暴露出来。注意,这里只可以一一列举,通配符 * 在这里是无效的。
  • maxAge:预检请求的有效期,有效期内不必再次发送预检请求,默认是1800秒。
  • methods:允许的请求方法,* 表示允许所有方法。
  • origins:允许的域,*表示允许所有域。

addCrosMapping
@CrossOrigin 注解需要添加在不同的 Controller 上。所以还有一种全局配置方法,就是通过重写 WebMvcConfigurerComposite#addCorsMappings方法来实现,具体配置如下:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer{
  Override
  public void addCorsMappings (CorsRegistry registry){
    registry.addMapping("/**") //处理的请求地址
    .allowedMethods ("*")
    .allowedorigins("*")
    .allowedHeaders ("*")
    .allowCredentials (false)
    .exposedHeaders ("")
    .maxAge (3600) ;
  }
}

CrosFilter
Cosr Filter 是Spring Web 中提供的一个处理跨域的过滤器,开发者也可以通过该过该过滤器处理跨域。

@Configuration
public class WebMvcConfig {
    @Bean
    FilterRegistrationBean<CorsFilter> corsFilter() {
        FilterRegistrationBean<CorsFilter> registrationBean = new FilterRegistrationBean<>();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
        corsConfiguration.setAllowedMethods(Arrays.asList("*"));
        corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
        corsConfiguration.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", corsConfiguration);
        registrationBean.setFilter(new CorsFilter(source));
        registrationBean.setOrder(-1);//filter 0 1
        return registrationBean;
    }
}

3、Spring Security 跨域解决方案

原理分析:
当我们为项目添加了 Spring Security 依赖之后,发现上面三种跨域方式有的失效了,有则可以继续使用,这是怎么回事?
通过@CrossOrigin 注解或者重写 addCorsMappings 方法配置跨域,统统失效了,通CorsFilter 配置的跨域,有没有失效则要看过滤器的优先级,如果过滤器优先级高于 SpringSecurity 过滤器,即先于 Spring Security 过滤器执行,则 CorsFiter 所配置的跨域处理依然有效;如果过滤器优先级低于 Spring Security 过滤器,则 CorsFilter 所配置的跨域处理就会失效。
为了理清楚这个问题,我们先简略了解一下 Filter、DispatchserServlet 以及Interceptor 执行顺序。

image.png

理清楚了执行顺序,我们再来看跨域请求过程。由于非简单请求都要首先发送一个预检请求request,而预检请求并不会携带认证信息,所以预检请求就有被 Spring Security 拦截的可能。因此通过@CrossOrigin 注解或者重写 addCorsMappings 方法配置跨域就会失效。如果使用 CorsFilter 配置的跨域,只要过滤器优先级高于 SpringSecurity 过滤器就不会有问题。反之同样会出现问题。

Spring Security 中也提供了更专业的方式来解决预检请求所面临的问题。如:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest()
                .authenticated()
                .and()
                .formLogin()
                .and()
                .cors() //跨域处理方案
                .configurationSource(configurationSource())
                .and()
                .csrf().disable();
    }

    CorsConfigurationSource configurationSource() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
        corsConfiguration.setAllowedMethods(Arrays.asList("*"));
        corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
        corsConfiguration.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", corsConfiguration);
        return source;
    }
}

十一、异常处理

Spring Security 中异常主要分为两大类:

  • AuthenticationException: 认证异常
  • AccessDeniedException: 授权异常

其中认证所涉及异常类型比较多,默认提供的异常类型如下:
image.png
相比于认证异常,权限异常类就要少了很多,默认提供的权限异常如下:
image.png
在实际项目开发中,如果默认提供异常无法满足需求时,就需要根据实际需要来自定义异常类。
自定义:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest()
                .authenticated()
          			//.....
                .and()
                .exceptionHandling()//异常处理
                .authenticationEntryPoint((request, response, e) -> {
                  response.setContentType("application/json;charset=UTF-8");
                  response.setStatus(HttpStatus.UNAUTHORIZED.value());
                  response.getWriter().write("尚未认证,请进行认证操作!");
                })
                .accessDeniedHandler((request, response, e) -> {
                  response.setContentType("application/json;charset=UTF-8");
                  response.setStatus(HttpStatus.FORBIDDEN.value());
                  response.getWriter().write("无权访问!");
                });
    }
}

十二、授权

1、授权核心概念

在前面学习认证过程中,我们得知认证成功之后会将当前登录用户信息保存到 Authentication 对象中,Authentication 对象中有一个 getAuthorities() 方法,用来返回当前登录用户具备的权限信息,也就是当前用户具有权限信息。该方法的返回值为 Collection<? extends GrantedAuthority>,当需要进行权限判断时,就回根据集合返回权限信息调用相应方法进行判断。

image.png

那么问题来了,针对于这个返回值 GrantedAuthority 应该如何理解呢? 是角色还是权限?

我们针对于授权可以是基于角色权限管理和基于资源权限管理 ,从设计层面上来说,角色和权限是两个完全不同的东西:权限是一些具体操作,角色则是某些权限集合。如:READ_BOOK 和 ROLE_ADMIN 是完全不同的。因此至于返回值是什么取决于你的业务设计情况:

  • 基于角色权限设计就是: 用户<=>角色<=>资源 三者关系 返回就是用户的角色
  • 基于资源权限设计就是: 用户<=>权限<=>资源 三者关系 返回就是用户的权限
  • 基于角色和资源权限设计就是: 用户<=>角色<=>权限<=>资源 返回统称为用户的权限

为什么可以统称为权限,因为从代码层面角色和权限没有太大不同都是权限,特别是在 Spring Security 中,角色和权限处理方式基本上都是一样的。唯一区别 SpringSecurity 在很多时候会自动给角色添加一个ROLE_前缀,而权限则不会自动添加。

2、权限管理策略

Spring Security 中提供的权限管理策略主要有两种类型:

  • 基于过滤器(URL)的权限管理 (FilterSecurityInterceptor)
    • 基于过滤器的权限管理主要是用来拦截 HTTP 请求,拦截下来之后,根据 HTTP 请求地址进行权限校验。
  • 基于 AOP (方法)的权限管理 (MethodSecurityInterceptor)
    • 基于 AOP 权限管理主要是用来处理方法级别的权限问题。当需要调用某一个方法时,通过 AOP 将操作拦截下来,然后判断用户是否具备相关的权限。

3、基于 URL 权限管理

@RestController
public class DemoController {

    @GetMapping("/admin")
    public String admin() {
        return "admin ok";
    }

    @GetMapping("/user")
    public String user() {
        return "user ok";
    }

    @GetMapping("/getInfo")
    public String getInfo() {
        return "info ok";
    }
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {


    //创建内存数据源
    public UserDetailsService userDetailsService() {
        //角色跟权限统一处理
        InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123").roles("ADMIN").build());
        inMemoryUserDetailsManager.createUser(User.withUsername("win7").password("{noop}123").roles("USER").build());
        inMemoryUserDetailsManager.createUser(User.withUsername("lisi").password("{noop}123").roles("READ_BOOK").build());
        return inMemoryUserDetailsManager;
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests()
                //权限表达式
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/user/**").hasAnyRole("USER", "ADMIN")
                .antMatchers("/getInfo").hasRole("READ_BOOK")
                .anyRequest().authenticated()
                .and().formLogin()
                .and().csrf().disable();
    }
}

权限表达式:
image.png

方法 说明
hasAuthority(String authority) 当前用户是否具备指定权限
hasAnyAuthority(String... authorities) 当前用户是否具备指定权限中任意一个
hasRole(String role) 当前用户是否具备指定角色
hasAnyRole(String... roles); 当前用户是否具备指定角色中任意一个
permitAll(); 放行所有请求/调用
denyAll(); 拒绝所有请求/调用
isAnonymous(); 当前用户是否是一个匿名用户
isAuthenticated(); 当前用户是否已经认证成功
isRememberMe(); 当前用户是否通过 Remember-Me 自动登录
isFullyAuthenticated(); 当前用户是否既不是匿名用户又不是通过 Remember-Me 自动登录的
hasPermission(Object targetId, Object permission); 当前用户是否具备指定目标的指定权限信息
hasPermission(Object targetId, String targetType, Object permission); 当前用户是否具备指定目标的指定权限信息

4、基于 方法 权限管理

基于方法的权限管理主要是通过 A0P 来实现的,Spring Security 中通过 MethodSecurityInterceptor 来提供相关的实现。不同在于 FilterSecurityInterceptor 只是在请求之前进行前置处理,MethodSecurityInterceptor 除了前置处理之外还可以进行后置处理。前置处理就是在请求之前判断是否具备相应的权限,后置处理则是对方法的执行结果进行二次过滤。前置处理和后置处理分别对应了不同的实现类。

**@EnableGlobalMethodSecurity **
EnableGlobalMethodSecurity 该注解是用来开启权限注解,用法如下:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled=true,securedEnabled=true, jsr250Enabled=true)
public class SecurityConfig extends WebsecurityConfigurerAdapter{}
  • perPostEnabled: 开启 Spring Security 提供的四个权限注解,@PostAuthorize、@PostFilter、@PreAuthorize 以及@PreFilter。
  • securedEnabled: 开启 Spring Security 提供的 @Secured 注解支持,该注解不支持权限表达式
  • jsr250Enabled: 开启 JSR-250 提供的注解,主要是@DenyAll、@PermitAll、@RolesAll 同样这些注解也不支持权限表达式
# 以上注解含义如下:
- @PostAuthorize: 在目前标方法执行之后进行权限校验。
- @PostFiter: 在目标方法执行之后对方法的返回结果进行过滤。
- @PreAuthorize:在目标方法执行之前进行权限校验。
- @PreFiter:在目前标方法执行之前对方法参数进行过滤。
- @Secured:访问目标方法必须具各相应的角色。
- @DenyAll:拒绝所有访问。
- @PermitAll:允许所有访问。
- @RolesAllowed:访问目标方法必须具备相应的角色。

这些基于方法的权限管理相关的注解,一般来说只要设置 prePostEnabled=true 就够用了。

基本使用:
1、开启注解

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled=true,securedEnabled=true, jsr250Enabled=true)
public class SecurityConfig extends WebsecurityConfigurerAdapter{}

2、使用注解

@RestController
@RequestMapping("/hello")
public class AuthorizeMethodController {

    //在目标方法执行之前进行权限校验。
    //校验该账号的角色是 ADMIN 并且 账号是 root
    @PreAuthorize("hasRole('ADMIN') and authentication.name=='root'")
    @GetMapping
    public String hello() {
        return "hello";
    }

    //在目标方法执行之前进行权限校验。
    //校验 当前登录账号 是否 跟该接口的入参相匹配
    @PreAuthorize("authentication.name==#name")
    @GetMapping("/name")
    public String hello(String name) {
        return "hello:" + name;
    }

    //在目前标方法执行之前对方法参数进行过滤。
    //filterTarget 为 入参数组,filterObject 为 入参数组的数据类型
    @PreFilter(value = "filterObject.id%2!=0",filterTarget = "users")
    @PostMapping("/users")  //filterTarget 必须是 数组  集合
    public void addUsers(@RequestBody List<User> users) {
        System.out.println("users = " + users);
    }

	//在目前标方法执行之后进行权限校验。 
    //returnObject 为 返参
    @PostAuthorize("returnObject.id==1")
    @GetMapping("/userId")
    public User getUserById(Integer id) {
        return new User(id, "blr");
    }

    //在目标方法执行之后对方法的返回结果进行过滤。
    //filterObject 为 返参
    @PostFilter("filterObject.id%2==0")
    @GetMapping("/lists")
    public List<User> getAll() {
        List<User> users = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            users.add(new User(i, "blr:" + i));
        }
        return users;
    }

    @Secured({"ROLE_USER"}) //只能判断角色
    @GetMapping("/secured")
    public User getUserByUsername() {
        return new User(99, "secured");
    }

    @Secured({"ROLE_ADMIN","ROLE_USER"}) //具有其中一个即可
    @GetMapping("/username")
    public User getUserByUsername2(String username) {
        return new User(99, username);
    }

    @PermitAll
    @GetMapping("/permitAll")
    public String permitAll() {
        return "PermitAll";
    }

    @DenyAll
    @GetMapping("/denyAll")
    public String denyAll() {
        return "DenyAll";
    }

    @RolesAllowed({"ROLE_ADMIN","ROLE_USER"}) //具有其中一个角色即可
    @GetMapping("/rolesAllowed")
    public String rolesAllowed() {
        return "RolesAllowed";
    }
}

image.png

  • ConfigAttribute 在 Spring Security 中,用户请求一个资源(通常是一个接口或者一个 Java 方法)需要的角色会被封装成一个 ConfigAttribute 对象,在 ConfigAttribute 中只有一个 getAttribute方法,该方法返回一个 String 字符串,就是角色的名称。一般来说,角色名称都带有一个 ROLE_ 前缀,投票器 AccessDecisionVoter 所做的事情,其实就是比较用户所具各的角色和请求某个资源所需的 ConfigAtuibute 之间的关系。
  • AccesDecisionVoter 和 AccessDecisionManager 都有众多的实现类,在 AccessDecisionManager 中会换个遍历 AccessDecisionVoter,进而决定是否允许用户访问,因而 AaccesDecisionVoter 和 AccessDecisionManager 两者的关系类似于 AuthenticationProvider 和 ProviderManager 的关系。

5、动态配置权限

首先,我们先看这个 FilterInvocationSecurityMetadataSource 接口,他继承了 SecurityMetadataSource 接口,SecurityMetadataSource 接口的 getAttributes 方法 被两个子类重写了

//默认,基于内存,根据map去取
@Override
public Collection<ConfigAttribute> getAttributes(Object object) {
    final HttpServletRequest request = ((FilterInvocation) object).getRequest();
    int count = 0;
    for (Map.Entry<RequestMatcher, Collection<ConfigAttribute>> entry : this.requestMap.entrySet()) {
        if (entry.getKey().matches(request)) {
            return entry.getValue();
        }
        else {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace(LogMessage.format("Did not match request to %s - %s (%d/%d)", entry.getKey(),
                        entry.getValue(), ++count, this.requestMap.size()));
            }
        }
    }
    return null;
}
@Override
public final Collection<ConfigAttribute> getAttributes(Object object) {
    if (object instanceof MethodInvocation) {
        MethodInvocation mi = (MethodInvocation) object;
        Object target = mi.getThis();
        Class<?> targetClass = null;
        if (target != null) {
            targetClass = (target instanceof Class<?>) ? (Class<?>) target
                    : AopProxyUtils.ultimateTargetClass(target);
        }
        Collection<ConfigAttribute> attrs = getAttributes(mi.getMethod(), targetClass);
        if (attrs != null && !attrs.isEmpty()) {
            return attrs;
        }
        if (target != null && !(target instanceof Class<?>)) {
            attrs = getAttributes(mi.getMethod(), target.getClass());
        }
        return attrs;
    }
    throw new IllegalArgumentException("Object must be a non-null MethodInvocation");
}

@Component
public class CustomSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    
    @Autowired
    private MenuService menuService;

    AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        String requestURI = ((FilterInvocation) object).getRequest().getRequestURI();
        List<Menu> allMenu = menuService.getAllMenu();
        for (Menu menu : allMenu) {
            if (antPathMatcher.match(menu.getPattern(), requestURI)) {
                String[] roles = menu.getRoles().stream().map(r -> r.getName()).toArray(String[]::new);
                return SecurityConfig.createList(roles);
            }
        }
        return null;
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomSecurityMetadataSource customSecurityMetadataSource;
    private final UserService userService;

    @Autowired
    public SecurityConfig(CustomSecurityMetadataSource customSecurityMetadataSource, UserService userService) {
        this.customSecurityMetadataSource = customSecurityMetadataSource;
        this.userService = userService;
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
        //配置权限
        http.apply(new UrlAuthorizationConfigurer<>(applicationContext))
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                        object.setSecurityMetadataSource(customSecurityMetadataSource);
                        object.setRejectPublicInvocations(true);
                        return object;
                    }
                });
        http.formLogin()
                .and()
                .csrf().disable();
    }
}
posted @ 2023-07-31 16:22  啊俊同学  阅读(193)  评论(0)    收藏  举报