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
标头。AccessDeniedException
,则拒绝访问。调用AccessDeniedHandler
来处理拒绝的访问。想要了解Spring Security
的过滤器链如何在Spring
应用程序中发挥作用,可以阅读下面这篇博客:
ExceptionTranslationFilter
会使用AuthenticationEntryPoint
启动身份验证方案。
public interface AuthenticationEntryPoint {
/**
* 启动身份验证方案
* 实现应根据需要修改ServletResponse的标头以开始身份验证过程
*/
void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException;
}
由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());
}
...
}
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);
}
/**
* 没有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");
}
}
由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;
}
...
}
在预验证的验证案例中,用户已经通过某种外部机制被识别,并且在调用Security Enforcement
过滤器时建立了一个安全上下文。因此,此类实际上并不负责身份验证的入口。 如果用户被AbstractPreAuthenticatedProcessingFilter
拒绝,它将被调用,从而导致null
身份验证。commence
方法将始终返回HttpServletResponse.SC_FORBIDDEN
(403
错误,除非拥有授权否则服务器拒绝提供所请求的资源)。
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");
}
}
发送通用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());
}
}
由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;
}
...
}
项目结构图:
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);
}
}
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
分析就到这里,如果博主有说错的地方或者大家有不同的见解,欢迎大家评论补充。
先自我介绍一下,小编13年上师交大毕业,曾经在小公司待过,去过华为OPPO等大厂,18年进入阿里,直到现在。深知大多数初中级java工程师,想要升技能,往往是需要自己摸索成长或是报班学习,但对于培训机构动则近万元的学费,着实压力不小。自己不成体系的自学效率很低又漫长,而且容易碰到天花板技术停止不前。因此我收集了一份《java开发全套学习资料》送给大家,初衷也很简单,就是希望帮助到想自学又不知道该从何学起的朋友,同时减轻大家的负担。添加下方名片,即可获取全套学习资料哦