Skip to content

Spring Security-初认识

Spring Security 是Spring官方提供的安全框架,提供了身份验证、授权和防止常见攻击功能。

先读Spring security的整体框架,了解大致的工作流程:https://docs.spring.io/spring-security/reference/servlet/architecture.html

参考视频:

1. 架构介绍

Spring Security通过servlet技术栈中的过滤器Filter发挥作用的,根据不同的路径配置不同的过滤器链。

过滤器链中每个过滤器作用不同,并按照次序依次发挥作用,过滤器的顺序按照功能排序的话,大致顺序可以认为是防止常见攻击、身份验证和授权。

springsecurityflow

2. 环境搭建与引入依赖

首先在Spring Boot项目中搭建web环境,只需要简单的Controller:

java
@RestController
public class HelloController {
    
    @GetMapping("/hello")
    public R hello(){
        return R.ok("hello spring security");
    }
}
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class R<T> {
    private int code;
    private String msg;
    @JsonRawValue
    private T data;

    public static <T> R ok(T data){
        return new R<T>(1, "success", data);
    }

    public static <T> R error(String msg){
        return new R<T>(-1, msg, null);
    }
}

然后引入Spring Security依赖:

java
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

当我们使用浏览器访问'/hello'路径时,会跳转到登录界面:

image-20241228224000039

用户名是user,密码在控制台中有打印。输入账号密码后,会显示接口'/hello'的结果。

3. 为什么?

在上面的实验中,仅仅是引入了Spring Security 依赖,整个程序就被保护起来了,Spring Security做了什么?

首先,再次明确Spring Security 是通过servlet技术栈中的过滤器Filter起作用的,换句话说,Spring Security引入了一系列过滤器。

我们可以通过在application.yml中更改日志打印级别:

yaml
logging:
  level:
    org:
      springframework:
        security: trace

可以配置账号密码,这样就不用使用Spring Security 自动生成的一长串密码:

java
spring:
	security:
    user:
      name: test
      password: test

这样,在程序启动时会输出以下的内容:

txt
Will secure any request with filters: 
DisableEncodeUrlFilter, 
WebAsyncManagerIntegrationFilter, 
SecurityContextHolderFilter, 
HeaderWriterFilter, 
CsrfFilter, 
LogoutFilter, 
UsernamePasswordAuthenticationFilter, 
DefaultResourcesFilter, 
DefaultLoginPageGeneratingFilter, 
DefaultLogoutPageGeneratingFilter, 
BasicAuthenticationFilter, 
RequestCacheAwareFilter, 
SecurityContextHolderAwareRequestFilter, 
AnonymousAuthenticationFilter, 
ExceptionTranslationFilter, 
AuthorizationFilter

3.1 问题一:请求的路径/hello,为什么地址栏变成了/login

答案当然是浏览器进行了重定向:

image-20241230184320997

可是重定向这个工作是怎么完成的呢?

首先,在FormLoginConfigurer初始化时,会调用父类AbstractAuthenticationFilterConfigurer的构造方法,其中设置了登录界面的URL为/login

java
public final class FormLoginConfigurer<H extends HttpSecurityBuilder<H>> extends
		AbstractAuthenticationFilterConfigurer<H, FormLoginConfigurer<H>, UsernamePasswordAuthenticationFilter> {
  // 1. FormLoginConfigurer 构造方法
	public FormLoginConfigurer() {
    // 2. 调用父类AbstractAuthenticationFilterConfigurer构造方法
		super(new UsernamePasswordAuthenticationFilter(), null);
		usernameParameter("username");
		passwordParameter("password");
	}
}

public abstract class AbstractAuthenticationFilterConfigurer<B extends HttpSecurityBuilder<B>, T extends AbstractAuthenticationFilterConfigurer<B, T, F>, F extends AbstractAuthenticationProcessingFilter>
		extends AbstractHttpConfigurer<T, B> {
  // 3. 构造方法-1
  protected AbstractAuthenticationFilterConfigurer(F authenticationFilter, String defaultLoginProcessingUrl) {
    // 4. 调用自身构造方法-2
    this();
    this.authFilter = authenticationFilter;
    if (defaultLoginProcessingUrl != null) {
      loginProcessingUrl(defaultLoginProcessingUrl);
    }
  }
  
  // 5.构造方法-2
  protected AbstractAuthenticationFilterConfigurer() {
  	setLoginPage("/login");
	}
  
  // 6.设置登录界面URL
  private void setLoginPage(String loginPage) {
		this.loginPage = loginPage;
    // 7. LoginUrlAuthenticationEntryPoint 注意该类
		this.authenticationEntryPoint = new LoginUrlAuthenticationEntryPoint(loginPage);
	}
}

ExceptionTranslationFilter过滤器中,它的作用主要是处理身份认证和授权过程中出现的异常,我们从doFilter方法跟踪,然后进入handleAuthenticationException()方法查看身份认证失败的处理逻辑:

java
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
    AuthenticationException reason) throws ServletException, IOException {
  // SEC-112: Clear the SecurityContextHolder's Authentication, as the
  // existing Authentication is no longer considered valid
  SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
  this.securityContextHolderStrategy.setContext(context);
  this.requestCache.saveRequest(request, response);
  
  // 我们的关注点
  this.authenticationEntryPoint.commence(request, response, reason);
}

还记得上面初始化的配置吗,authenticationEntryPointLoginUrlAuthenticationEntryPoint实例,我们查看其commence实现:

java
public void commence(HttpServletRequest request, HttpServletResponse response,
    AuthenticationException authException) throws IOException, ServletException {
  if (!this.useForward) {
    // redirect to login page. Use https if forceHttps true
    String redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
    // 重定向
    this.redirectStrategy.sendRedirect(request, response, redirectUrl);
    return;
  }
  // 以下代码省略
  ......
}

RedirectStrategy默认实现中,实现重定向逻辑:

java
public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
  // 1. 获取重定向地址
  String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
  redirectUrl = response.encodeRedirectURL(redirectUrl);
  // 2. 状态码校验 HttpStatus.FOUND 就是302重定向
  if (this.statusCode == HttpStatus.FOUND) {
    // 直接重定向
    response.sendRedirect(redirectUrl);
  }
  else {
    response.setHeader(HttpHeaders.LOCATION, redirectUrl);
    response.setStatus(this.statusCode.value());
    response.getWriter().flush();
  }
}

所以这就解决了我们的问题,为什么请求的是/hello路径,但浏览器地址栏变成了/login

3.2 问题二:登录界面是怎么来的?

浏览器重定向后,发起第二次请求/login

在上面的拦截器中,有DefaultLoginPageGeneratingFilter,见名知意,该过滤器是用于创建默认的登录界面的,在其拦截方法中可以找到相关代码:

java
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
  // 是否登录错误
  boolean loginError = isErrorPage(request);
  // 是否退出登录成功
  boolean logoutSuccess = isLogoutSuccess(request);
  // 当前请求的是登录界面 或 登录错误 或 退出登录成功
  if (isLoginUrlRequest(request) || loginError || logoutSuccess) {
    // 创建登录界面
    String loginPageHtml = generateLoginPageHtml(request, loginError, logoutSuccess);
    // 写响应并返回
    response.setContentType("text/html;charset=UTF-8");
    response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
    response.getWriter().write(loginPageHtml);
    return;
  }
  // 如果不是上述情况,则放行
  chain.doFilter(request, response);
}

3.3 问题三:Spring Security是如何校验账号密码的?

我们关注两个过滤器:

  • UsernamePasswordAuthenticationFilter:用于校验表单登录;
  • BasicAuthenticationFilter:用户请求头中Authorization方式登录;

并且,要在AuthorizationFilter的配合下发挥作用,我们在下一篇文章中讲解。

3.4 问题四:为什么登录成功后,返回了/hello路径的结果

主要流程如图:

springsecurityflow2.

  1. 首先浏览器访问/hello路径,Spring Security身份验证失败,重定向到/login,并将初始请求URL保存到session中,在响应中设置session id;
  2. 浏览器接收到重定向响应后,重新请求/login(GET方式)页面,DefaultLoginPageGeneratingFilter过滤器返回登录界面;
  3. 用户输入账号密码后,请求/login(POST方式),UsernamePasswordAuthenticationFilter校验表单登录,如果登录成功,则获取请求头中携带的session id ,将session中保存的URL提取出来重定向;登录成功后,会创建一个新的session,然后将登录信息保存到新的session中,响应携带最新的session id;
  4. 浏览器收到重定向响应后,重新请求/hello接口,并携带最新的session id,Spring Security通过session id 校验用户是否登录,然后返回结果。