Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析

2年前 (2022) 程序员胖胖胖虎阿
266 0 0

ExceptionTranslationFilter

ExceptionTranslationFilterSecurity Filter)允许将AccessDeniedExceptionAuthenticationException转换为HTTP响应。ExceptionTranslationFilter作为Security Filters之一插入到FilterChainProxy中。

Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析

  1. 首先,ExceptionTranslationFilter调用FilterChain.doFilter(request, response),即调用应用程序的其余部分(出现异常才执行自己的逻辑)。
    Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
    Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
  2. 如果用户未经身份验证或是身份验证异常,则启动身份验证。
    Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
    1. 清除SecurityContextHolder的身份验证(SEC-112:清除SecurityContextHolder的身份验证,因为现有身份验证不再有效)。
    2. HttpServletRequest保存在RequestCache中。当用户成功进行身份验证时,RequestCache用于重现原始请求。
    3. AuthenticationEntryPoint用于从客户端请求凭据。例如,它可能会重定向到登录页面或发送WWW-Authenticate标头。
      Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
  3. 否则,如果是AccessDeniedException,则拒绝访问。调用AccessDeniedHandler来处理拒绝的访问。
    Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析

想要了解Spring Security的过滤器链如何在Spring应用程序中发挥作用,可以阅读下面这篇博客:

  • Spring Security:介绍 & 初体验 & 源码与日志分析

AuthenticationEntryPoint

ExceptionTranslationFilter会使用AuthenticationEntryPoint启动身份验证方案。

public interface AuthenticationEntryPoint {
	/**
	 * 启动身份验证方案
	 * 实现应根据需要修改ServletResponse的标头以开始身份验证过程
	 */
	void commence(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException authException) throws IOException, ServletException;
}

BasicAuthenticationEntryPoint

ExceptionTranslationFilter用于通过BasicAuthenticationFilter开始身份验证。一旦使用BASIC对用户代理进行身份验证,可以发送未经授权的 (401) 标头,最简单的方法是调用BasicAuthenticationEntryPoint类的commence方法。 这将向浏览器指示其凭据不再被授权,导致它提示用户再次登录。

public class BasicAuthenticationEntryPoint implements AuthenticationEntryPoint,
		InitializingBean {
	// 领域名称
	private String realmName;

	// 检查属性
	public void afterPropertiesSet() {
		Assert.hasText(realmName, "realmName must be specified");
	}

	public void commence(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException authException) throws IOException {
		// 填充响应
		response.addHeader("WWW-Authenticate", "Basic realm=\"" + realmName + "\"");
		response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
	}
	...
}

DelegatingAuthenticationEntryPoint

AuthenticationEntryPoint实现,它根据RequestMatcher匹配(委托)一个具体的AuthenticationEntryPoint

public class DelegatingAuthenticationEntryPoint implements AuthenticationEntryPoint,
		InitializingBean {
	private final Log logger = LogFactory.getLog(getClass());

    // RequestMatcher与AuthenticationEntryPoint的映射
	private final LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints;
	// 默认AuthenticationEntryPoint
	private AuthenticationEntryPoint defaultEntryPoint;
    // 构造方法
	public DelegatingAuthenticationEntryPoint(
			LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints) {
		this.entryPoints = entryPoints;
	}

	public void commence(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException authException) throws IOException, ServletException {
        // 遍历entryPoints
		for (RequestMatcher requestMatcher : entryPoints.keySet()) {
			if (logger.isDebugEnabled()) {
				logger.debug("Trying to match using " + requestMatcher);
			}
			// 如果RequestMatcher匹配请求
			if (requestMatcher.matches(request)) {
			    // 获取匹配请求的RequestMatcher对应的AuthenticationEntryPoint
				AuthenticationEntryPoint entryPoint = entryPoints.get(requestMatcher);
				if (logger.isDebugEnabled()) {
					logger.debug("Match found! Executing " + entryPoint);
				}
				// 委托给匹配请求的RequestMatcher对应的AuthenticationEntryPoint
				entryPoint.commence(request, response, authException);
				return;
			}
		}

		if (logger.isDebugEnabled()) {
			logger.debug("No match found. Using default entry point " + defaultEntryPoint);
		}

		// 没有匹配的身份验证入口,使用defaultEntryPoint
		defaultEntryPoint.commence(request, response, authException);
	}

	/**
	 * 没有RequestMatcher返回true时使用的EntryPoint(默认)
	 */
	public void setDefaultEntryPoint(AuthenticationEntryPoint defaultEntryPoint) {
		this.defaultEntryPoint = defaultEntryPoint;
	}
    // 检查属性
	public void afterPropertiesSet() {
		Assert.notEmpty(entryPoints, "entryPoints must be specified");
		Assert.notNull(defaultEntryPoint, "defaultEntryPoint must be specified");
	}
}

DigestAuthenticationEntryPoint

SecurityEnforcementFilter用于通过DigestAuthenticationFilter开始身份验证。发送回用户代理的随机数将在setNonceValiditySeconds(int)指示的时间段内有效,默认情况下为300秒。 如果重放攻击是主要问题,则应使用更短的时间。如果性能更受关注,则可以使用更大的值。当nonce过期时,此类正确显示stale=true标头,因此正确实施的用户代理将自动与新的nonce值重新协商(即不向用户显示新的密码对话框)。

public class DigestAuthenticationEntryPoint implements AuthenticationEntryPoint,
		InitializingBean, Ordered {
	private static final Log logger = LogFactory
			.getLog(DigestAuthenticationEntryPoint.class);

	// 用于验证用户身份的字符串键值
	private String key;
	// 领域名称
	private String realmName;
	// nonce有效时间
	private int nonceValiditySeconds = 300;
	private int order = Integer.MAX_VALUE; 

	...
	
    // 检查属性
	public void afterPropertiesSet() {
		if ((realmName == null) || "".equals(realmName)) {
			throw new IllegalArgumentException("realmName must be specified");
		}

		if ((key == null) || "".equals(key)) {
			throw new IllegalArgumentException("key must be specified");
		}
	}

	public void commence(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException authException) throws IOException {
		HttpServletResponse httpResponse = response;

		// 计算随机数(由于代理,请勿使用远程IP地址)
		// 随机数格式为:base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
		// 过期时间
		long expiryTime = System.currentTimeMillis() + (nonceValiditySeconds * 1000);
		// 由下面三个步骤计算随机数
		String signatureValue = DigestAuthUtils.md5Hex(expiryTime + ":" + key);
		String nonceValue = expiryTime + ":" + signatureValue;
		String nonceValueBase64 = new String(Base64.getEncoder().encode(nonceValue.getBytes()));

		// 用于填充响应的验证Header
		String authenticateHeader = "Digest realm=\"" + realmName + "\", "
				+ "qop=\"auth\", nonce=\"" + nonceValueBase64 + "\"";

		if (authException instanceof NonceExpiredException) {
			authenticateHeader = authenticateHeader + ", stale=\"true\"";
		}

		if (logger.isDebugEnabled()) {
			logger.debug("WWW-Authenticate header sent to user agent: "
					+ authenticateHeader);
		}
		
        // 填充响应
		httpResponse.addHeader("WWW-Authenticate", authenticateHeader);
		httpResponse.sendError(HttpStatus.UNAUTHORIZED.value(),
			HttpStatus.UNAUTHORIZED.getReasonPhrase());
	}
	
    // 设置key属性
    public void setKey(String key) {
		this.key = key;
	}
	
	...
}

Http403ForbiddenEntryPoint

在预验证的验证案例中,用户已经通过某种外部机制被识别,并且在调用Security Enforcement过滤器时建立了一个安全上下文。因此,此类实际上并不负责身份验证的入口。 如果用户被AbstractPreAuthenticatedProcessingFilter拒绝,它将被调用,从而导致null身份验证。commence方法将始终返回HttpServletResponse.SC_FORBIDDEN403 错误,除非拥有授权否则服务器拒绝提供所请求的资源)。

public class Http403ForbiddenEntryPoint implements AuthenticationEntryPoint {
	private static final Log logger = LogFactory.getLog(Http403ForbiddenEntryPoint.class);

	/**
	 * 始终向客户端返回403错误代码
	 */
	public void commence(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException arg2) throws IOException {
		if (logger.isDebugEnabled()) {
			logger.debug("Pre-authenticated entry point called. Rejecting access");
		}
		response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
	}
}

HttpStatusEntryPoint

发送通用HttpStatus作为响应的AuthenticationEntryPoint。对于由浏览器拦截响应而无法使用Basic身份验证的JavaScript客户端很有用。

public final class HttpStatusEntryPoint implements AuthenticationEntryPoint {
    // 用于设置响应的状态码
	private final HttpStatus httpStatus;

	/**
	 * 构造方法
	 */
	public HttpStatusEntryPoint(HttpStatus httpStatus) {
		Assert.notNull(httpStatus, "httpStatus cannot be null");
		this.httpStatus = httpStatus;
	}

	public void commence(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException authException) {
		// 根据httpStatus属性的值,设置响应的状态码
		response.setStatus(httpStatus.value());
	}
}

LoginUrlAuthenticationEntryPoint

ExceptionTranslationFilter用于通过UsernamePasswordAuthenticationFilter开始表单登录身份验证。在loginFormUrl属性中保存登录表单的URL,并使用它来构建到登录页面的重定向URL。或者,可以在此属性中设置绝对URL

使用相对URL时,可以将forceHttps属性设置为true,以强制用于登录表单的协议为HTTPS,即使原始截获的资源请求使用HTTP协议。发生这种情况时,在成功登录(通过 HTTPS)后,原始资源仍将通过原始请求URL作为HTTP访问。如果使用绝对URL,则forceHttps属性的值将不起作用。

public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoint,
		InitializingBean {

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

    // 向调用者提供有关哪些HTTP端口与系统上的哪些HTTPS端口相关联的信息
	private PortMapper portMapper = new PortMapperImpl();
    // 端口解析器,基于请求解析出端口
	private PortResolver portResolver = new PortResolverImpl();
    // 登陆页面URL
	private String loginFormUrl;
    // 默认为false,即不强制Https转发或重定向
	private boolean forceHttps = false;
    // 默认为false,即不是转发到登陆页面,而是进行重定向
	private boolean useForward = false;
    // 重定向策略
	private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

	/**
	 * loginFormUrl – 可以找到登录页面的URL
	 * 应该是相对于web-app上下文路径(包括前导/)或绝对URL
	 */
	public LoginUrlAuthenticationEntryPoint(String loginFormUrl) {
		Assert.notNull(loginFormUrl, "loginFormUrl cannot be null");
		this.loginFormUrl = loginFormUrl;
	}

	// 检查属性
	public void afterPropertiesSet() {
		Assert.isTrue(
				StringUtils.hasText(loginFormUrl)
						&& UrlUtils.isValidRedirectUrl(loginFormUrl),
				"loginFormUrl must be specified and must be a valid redirect URL");
		if (useForward && UrlUtils.isAbsoluteUrl(loginFormUrl)) {
			throw new IllegalArgumentException(
					"useForward must be false if using an absolute loginFormURL");
		}
		Assert.notNull(portMapper, "portMapper must be specified");
		Assert.notNull(portResolver, "portResolver must be specified");
	}

	/**
	 * 允许子类修改成适用于给定请求的登录表单URL
	 */
	protected String determineUrlToUseForThisRequest(HttpServletRequest request,
			HttpServletResponse response, AuthenticationException exception) {

		return getLoginFormUrl();
	}

	/**
	 * 执行到登录表单URL的重定向(或转发)
	 */
	public void commence(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException authException) throws IOException, ServletException {

		String redirectUrl = null;
        // 如果使用转发
		if (useForward) {
			if (forceHttps && "http".equals(request.getScheme())) {
				// 首先将当前请求重定向到HTTPS
				// 当收到该请求时,将使用到登录页面的转发
				redirectUrl = buildHttpsRedirectUrlForRequest(request);
			}
            // 如果重定向地址为null
			if (redirectUrl == null) {
			    // 获取登陆表单URL
				String loginForm = determineUrlToUseForThisRequest(request, response,
						authException);

				if (logger.isDebugEnabled()) {
					logger.debug("Server side forward to: " + loginForm);
				}
                // RequestDispatcher用于接收来自客户端的请求并将它们发送到服务器上的任何资源
				RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
                // 进行转发
				dispatcher.forward(request, response);

				return;
			}
		}
		else {
			// 重定向到登录页面
			// 如果forceHttps为真,则使用https
			redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);

		}
        // 进行重定向
		redirectStrategy.sendRedirect(request, response, redirectUrl);
	}

    // 构建重定向URL
	protected String buildRedirectUrlToLoginPage(HttpServletRequest request,
			HttpServletResponse response, AuthenticationException authException) {
        // 通过determineUrlToUseForThisRequest方法获取URL
		String loginForm = determineUrlToUseForThisRequest(request, response,
				authException);
        // 如果是绝对URL,直接返回
		if (UrlUtils.isAbsoluteUrl(loginForm)) {
			return loginForm;
		}
        // 如果是相对URL
        // 构造重定向URL
		int serverPort = portResolver.getServerPort(request);
		String scheme = request.getScheme();

		RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder();

		urlBuilder.setScheme(scheme);
		urlBuilder.setServerName(request.getServerName());
		urlBuilder.setPort(serverPort);
		urlBuilder.setContextPath(request.getContextPath());
		urlBuilder.setPathInfo(loginForm);

		if (forceHttps && "http".equals(scheme)) {
			Integer httpsPort = portMapper.lookupHttpsPort(serverPort);

			if (httpsPort != null) {
				// 覆盖重定向URL中的scheme和port
				urlBuilder.setScheme("https");
				urlBuilder.setPort(httpsPort);
			}
			else {
				logger.warn("Unable to redirect to HTTPS as no port mapping found for HTTP port "
						+ serverPort);
			}
		}

		return urlBuilder.getUrl();
	}

	/**
	 * 构建一个URL以将提供的请求重定向到HTTPS
	 * 用于在转发到登录页面之前将当前请求重定向到HTTPS
	 */
	protected String buildHttpsRedirectUrlForRequest(HttpServletRequest request)
			throws IOException, ServletException {

		int serverPort = portResolver.getServerPort(request);
		Integer httpsPort = portMapper.lookupHttpsPort(serverPort);

		if (httpsPort != null) {
			RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder();
			urlBuilder.setScheme("https");
			urlBuilder.setServerName(request.getServerName());
			urlBuilder.setPort(httpsPort);
			urlBuilder.setContextPath(request.getContextPath());
			urlBuilder.setServletPath(request.getServletPath());
			urlBuilder.setPathInfo(request.getPathInfo());
			urlBuilder.setQuery(request.getQueryString());

			return urlBuilder.getUrl();
		}

		// 通过警告消息进入服务器端转发
		logger.warn("Unable to redirect to HTTPS as no port mapping found for HTTP port "
				+ serverPort);

		return null;
	}

	/**
	 * 设置为true以强制通过https访问登录表单
	 * 如果此值为true(默认为false),并且触发拦截器的请求还不是https
	 * 则客户端将首先重定向到https URL,即使serverSideRedirect(服务器端转发)设置为true
	 */
	public void setForceHttps(boolean forceHttps) {
		this.forceHttps = forceHttps;
	}

    ...
    
	/**
	 * 是否要使用RequestDispatcher转发到loginFormUrl,而不是302重定向
	 */
	public void setUseForward(boolean useForward) {
		this.useForward = useForward;
	}
	
	...
}

Debug分析

项目结构图:
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.kaven</groupId>
    <artifactId>security</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.6.RELEASE</version>
    </parent>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

</project>

application.yml

spring:
  security:
    user:
      name: kaven
      password: itkaven
logging:
  level:
    org:
      springframework:
        security: DEBUG

MessageController(定义接口):

package com.kaven.security.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MessageController {
    @GetMapping("/message")
    public String getMessage() {
        return "hello spring security";
    }
}

启动类:

package com.kaven.security;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }
}

formLogin

SecurityConfigSpring Security的配置类,不是必须的,因为有默认的配置):

package com.kaven.security.config;

import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 任何请求都需要进行验证
        http.authorizeRequests()
                .anyRequest()
                .authenticated()
                .and()
                 // 记住身份验证
                .rememberMe(Customizer.withDefaults())
                 // 基于表单登陆的身份验证方式
                .formLogin(Customizer.withDefaults());
    }
}

Debug方式启动应用,访问http://localhost:8080/message,请求会被ExceptionTranslationFilter进行处理,该过滤器会调用身份验证入口AuthenticationEntryPointcommence方法,该身份验证入口是LoginUrlAuthenticationEntryPoint实例,并且该实例loginFormUrl属性的值为/login
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
LoginUrlAuthenticationEntryPoint实例会将请求重定向到http://localhost:8080/login
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
浏览器上的请求便被重定向到http://localhost:8080/login,输入正确的用户名和密码,点击登陆即可通过身份验证。
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
身份验证成功。
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
成功访问到资源。
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析

Basic

修改SecurityConfig类,如下所示:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 任何请求都需要进行验证
        http.authorizeRequests()
                .anyRequest()
                .authenticated()
                .and()
                 // 记住身份验证
                .rememberMe(Customizer.withDefaults())
                 // 基于Basic方式进行身份验证
                .httpBasic(Customizer.withDefaults());
    }

Debug方式启动应用,访问http://localhost:8080/message,请求会被ExceptionTranslationFilter进行处理,该过滤器会调用身份验证入口AuthenticationEntryPointcommence方法,该身份验证入口是DelegatingAuthenticationEntryPoint实例,并且该实例的defaultEntryPoint属性为BasicAuthenticationEntryPoint实例。
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
DelegatingAuthenticationEntryPoint实例会委托它的defaultEntryPoint属性进行处理,即BasicAuthenticationEntryPoint实例。
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
Basic身份验证如下图所示:
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
输入用户名和密码进行登陆,登陆请求会被BasicAuthenticationFilter进行处理,该过滤器会创建UsernamePasswordAuthenticationToken实例(身份验证请求令牌)用于验证。
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析

Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
最后会验证成功。
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析
成功访问到资源。
Spring Security:身份验证入口AuthenticationEntryPoint介绍与Debug分析

身份验证入口AuthenticationEntryPoint介绍与Debug分析就到这里,如果博主有说错的地方或者大家有不同的见解,欢迎大家评论补充。

相关文章

暂无评论

暂无评论...