ExceptionTranslationFilter(Security Filter)允许将AccessDeniedException和AuthenticationException转换为HTTP响应。ExceptionTranslationFilter作为Security Filters之一插入到FilterChainProxy中。
- 首先,ExceptionTranslationFilter调用FilterChain.doFilter(request, response),即调用应用程序的其余部分(出现异常才执行自己的逻辑)。
如果用户未经身份验证或是身份验证异常,则启动身份验证。
- 清除SecurityContextHolder的身份验证(SEC-112:清除SecurityContextHolder的身份验证,因为现有身份验证不再有效)。将HttpServletRequest保存在RequestCache中。当用户成功进行身份验证时,RequestCache用于重现原始请求。AuthenticationEntryPoint用于从客户端请求凭据。例如,它可能会重定向到登录页面或发送WWW-Authenticate标头。
想要了解Spring Security的过滤器链如何在Spring应用程序中发挥作用,可以阅读下面这篇博客:
Spring Security:介绍 & 初体验 & 源码与日志分析 AuthenticationEntryPoint
ExceptionTranslationFilter会使用AuthenticationEntryPoint启动身份验证方案。
public interface AuthenticationEntryPoint {
void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException;
}
BasicAuthenticationEntryPoint
由ExceptionTranslationFilter用于通过BasicAuthenticationFilter开始身份验证。一旦使用BASIC对用户代理进行身份验证,注销需要关闭浏览器或发送未经授权的 (401) 标头。 实现后者最简单的方法是调用BasicAuthenticationEntryPoint类的commence(HttpServletRequest, HttpServletResponse, AuthenticationException)方法。 这将向浏览器指示其凭据不再被授权,导致它提示用户再次登录。
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 entryPoints;
// 默认AuthenticationEntryPoint
private AuthenticationEntryPoint defaultEntryPoint;
// 构造方法
public DelegatingAuthenticationEntryPoint(
linkedHashMap 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);
}
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
在预验证的验证案例中(与CAS不同),用户已经通过某种外部机制被识别,并且在调用security-enforcement过滤器时建立了一个安全上下文。因此,此类实际上并不负责身份验证的入口,就像其他提供者的情况一样。 如果用户被AbstractPreAuthenticatedProcessingFilter拒绝,它将被调用,从而导致null身份验证。commence方法将始终返回HttpServletResponse.SC_FORBIDDEN (403 错误,除非拥有授权否则服务器拒绝提供所请求的资源)。
public class Http403ForbiddenEntryPoint implements AuthenticationEntryPoint {
private static final Log logger = LogFactory.getLog(Http403ForbiddenEntryPoint.class);
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();
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");
}
protected String determineUrlToUseForThisRequest(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception) {
return getLoginFormUrl();
}
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();
}
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;
}
public void setForceHttps(boolean forceHttps) {
this.forceHttps = forceHttps;
}
...
public void setUseForward(boolean useForward) {
this.useForward = useForward;
}
...
}
Debug分析
项目结构图:
pom.xml:
4.0.0 com.kaven security 1.0-SNAPSHOT org.springframework.boot spring-boot-starter-parent 2.3.6.RELEASE 8 8 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-security org.projectlombok lombok
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
SecurityConfig(Spring 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进行处理,该过滤器会调用身份验证入口AuthenticationEntryPoint的commence方法,该身份验证入口是LoginUrlAuthenticationEntryPoint实例,并且该实例loginFormUrl属性的值为/login。
该LoginUrlAuthenticationEntryPoint实例会将请求重定向到http://localhost:8080/login。
浏览器上的请求便被重定向到http://localhost:8080/login,输入正确的用户名和密码,点击登陆即可通过身份验证。
身份验证成功。
成功访问到资源。
修改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进行处理,该过滤器会调用身份验证入口AuthenticationEntryPoint的commence方法,该身份验证入口是DelegatingAuthenticationEntryPoint实例,并且该实例的defaultEntryPoint属性为BasicAuthenticationEntryPoint实例。
该DelegatingAuthenticationEntryPoint实例会委托它的defaultEntryPoint属性进行处理,即BasicAuthenticationEntryPoint实例。
Basic身份验证如下图所示:
输入用户名和密码进行登陆,登陆请求会被BasicAuthenticationFilter进行处理,该过滤器会创建UsernamePasswordAuthenticationToken实例(身份验证令牌)用于验证。
最后会验证成功。
成功访问到资源。
身份验证入口AuthenticationEntryPoint介绍与Debug分析就到这里,如果博主有说错的地方或者大家有不同的见解,欢迎大家评论补充。



