今天的主题就是使用单独部署的登录页面替换认证服务器默认的登录页面(前后端分离时使用前端的登录页面),目前在网上能搜到的很多都是理论,没有很好的一个示例,我就按照我自己的想法写了一个实现,给大家提供一个思路,如果有什么问题或者更好的想法可以在评论区提出,谢谢。
实现思路分析
先看一下在默认情况下请求在框架中的跳转情况
Spring Authorization Server(Spring Security)框架默认使用session存储用户的认证信息,这样在登录以后重定向回请求授权接口时(/oauth2/authorize)处理该请求的过滤器可以从session中获取到认证信息,从而走后边的流程,但是当这一套放在单独部署的登录页面中就不行了,在请求授权时哪怕登录过也无法获取到认证信息,因为他们不再是同一个session中了;所以关键点就在于怎么存储、获取认证信息。
先查看下框架怎么获取认证信息
在处理/oauth2/authorize接口的过滤器OAuth2AuthorizationEndpointFilter
中看一下实现逻辑,看一下对于认证信息的处理,如下图
先由converter处理,之后再由provider处理,之后判断认证信息是否已经认证过了,没认证过不处理,交给后边的过滤器处理,接下来看一下converter中的逻辑,如下图所示
如图所示,这里直接从SecurityContextHolder
中获取的认证信息,那么接下来就需要找一下它是怎么获取认证信息并放入SecurityContextHolder
中的。
在OAuth2AuthorizationEndpointFilter
中打一个断点,请求一下/oauth2/authorize接口,
断点断住以后查看一下过滤器链,发现在OAuth2AuthorizationEndpointFilter
之前有一个SecurityContextHolderFilter
过滤器,名字表达的特征很明显,接下来看一下这个过滤器中的逻辑。
从断点截图中可以看出是从securityContextRepository中获取的认证信息,然后通过securityContextHolderStrategy保存,看一下是不是在这里设置的认证信息。
断点进入方法后发现将认证信息的context设置到了contextHolder中,那这里和SecurityContextHolder是同一个东西吗?请接着往下看
SecurityContextHolder
的getContext方法是从当前类中的属性获取,接下来看一下securityContextHolderStrategy的定义
它是通过调用SecurityContextHolder的getContextHolderStrategy方法完成实例化的,看下这个方法
追踪到这里应该就差不多了,框架从securityContextRepository中获取认证信息,然后通过securityContextHolderStrategy放入SecurityContextHolder中,让后边的过滤器可以直接从SecurityContextHolder中获取认证信息。
获取认证信息的地方结束了,接下来看一下存储认证信息的地方,分析完获取的地方,存储的地方就很简单了。
存储认证信息
看过之前文章或者其它关于登录分析的文章应该知道,框架对于登录的处理是基于UsernamePasswordAuthenticationFilter
和父类AbstractAuthenticationProcessingFilter
,在父类中调用子类的校验,重点是认证成功后的处理,如下图
认证成功后调用了successfulAuthentication方法,看一下该方法的实现
其它的不是本篇文章的重点,主要是红框中的代码,这里将登陆后的认证信息存储在securityContextRepository中。
到这里逻辑就通了,登录后将认证信息存储在securityContextRepository中,访问时从securityContextRepository中取出认证信息并放在SecurityContextHolder中,这样就保持了登陆状态。
改造分析
使用前后端分离的登录页面,那么登录接口就需要响应json了,不能再使用默认的成功/失败处理了,所以要重写登录成功和失败的处理器;重定向也不能由认证服务来重定向了,应该由前端重定向;存储认证信息的容器也不能以session为主了,使用redis来替换session。
使用redis后没有session了,也就不能确定请求是哪一个,本人拙见是在登录时携带一个唯一字符串,请求成功后前端重定向至需要认证的请求时携带该唯一字符串,这样请求时可以根据这个唯一字符串获取到认证信息。
思路清晰以后编码就很快了
代码实现
1. 创建LoginSuccessHandler类并实现AuthenticationSuccessHandler
接口
package com.example.authorization.handler;
import com.example.model.Result;
import com.example.util.JsonUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 登录成功处理类
*
* @author vains
*/
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
Result success = Result.success();
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(JsonUtils.objectCovertToJson(success));
response.getWriter().flush();
}
}
2. 创建LoginFailureHandler类并实现AuthenticationFailureHandler
接口
package com.example.authorization.handler;
import com.example.model.Result;
import com.example.util.JsonUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 登录失败处理类
*
* @author vains
*/
public class LoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
// 登录失败,写回401与具体的异常
Result success = Result.error(HttpStatus.UNAUTHORIZED.value(), exception.getMessage());
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(JsonUtils.objectCovertToJson(success));
response.getWriter().flush();
}
}
3. 创建LoginTargetAuthenticationEntryPoint类并继承LoginUrlAuthenticationEntryPoint
类
package com.example.authorization.handler;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.util.ObjectUtils;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* 重定向至登录处理
*
* @author vains
*/
public class LoginTargetAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
/**
* @param loginFormUrl URL where the login page can be found. Should either be
* relative to the web-app context path (include a leading {@code /}) or an absolute
* URL.
*/
public LoginTargetAuthenticationEntryPoint(String loginFormUrl) {
super(loginFormUrl);
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 获取登录表单的地址
String loginForm = determineUrlToUseForThisRequest(request, response, authException);
if (!UrlUtils.isAbsoluteUrl(loginForm)) {
// 不是绝对路径调用父类方法处理
super.commence(request, response, authException);
return;
}
StringBuffer requestUrl = request.getRequestURL();
if (!ObjectUtils.isEmpty(request.getQueryString())) {
requestUrl.append("?").append(request.getQueryString());
}
// 绝对路径在重定向前添加target参数
String targetUrl = URLEncoder.encode(requestUrl.toString(), StandardCharsets.UTF_8);
this.redirectStrategy.sendRedirect(request, response, (loginForm + "?target=" + targetUrl));
}
}
4. 在support包下创建RedisSecurityContextRepository并实现SecurityContextRepository
package com.example.support;
import com.example.model.security.SupplierDeferredSecurityContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.DeferredSecurityContext;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.web.context.HttpRequestResponseHolder;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import java.util.function.Supplier;
import static com.example.constant.RedisConstants.DEFAULT_TIMEOUT_SECONDS;
import static com.example.constant.RedisConstants.SECURITY_CONTEXT_PREFIX_KEY;
import static com.example.constant.SecurityConstants.NONCE_HEADER_NAME;
/**
* 基于redis存储认证信息
*
* @author vains
*/
@Component
@RequiredArgsConstructor
public class RedisSecurityContextRepository implements SecurityContextRepository {
private final RedisOperator redisOperator;
private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
.getContextHolderStrategy();
@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
// HttpServletRequest request = requestResponseHolder.getRequest();
// return readSecurityContextFromRedis(request);
// 方法已过时,使用 loadDeferredContext 方法
throw new UnsupportedOperationException("Method deprecated.");
}
@Override
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
String nonce = request.getHeader(NONCE_HEADER_NAME);
if (ObjectUtils.isEmpty(nonce)) {
nonce = request.getParameter(NONCE_HEADER_NAME);
if (ObjectUtils.isEmpty(nonce)) {
return;
}
}
// 如果当前的context是空的,则移除
SecurityContext emptyContext = this.securityContextHolderStrategy.createEmptyContext();
if (emptyContext.equals(context)) {
redisOperator.delete((SECURITY_CONTEXT_PREFIX_KEY + nonce));
} else {
// 保存认证信息
redisOperator.set((SECURITY_CONTEXT_PREFIX_KEY + nonce), context, DEFAULT_TIMEOUT_SECONDS);
}
}
@Override
public boolean containsContext(HttpServletRequest request) {
String nonce = request.getHeader(NONCE_HEADER_NAME);
if (ObjectUtils.isEmpty(nonce)) {
nonce = request.getParameter(NONCE_HEADER_NAME);
if (ObjectUtils.isEmpty(nonce)) {
return false;
}
}
// 检验当前请求是否有认证信息
return redisOperator.get((SECURITY_CONTEXT_PREFIX_KEY + nonce)) != null;
}
@Override
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
Supplier supplier = () -> readSecurityContextFromRedis(request);
return new SupplierDeferredSecurityContext(supplier, this.securityContextHolderStrategy);
}
/**
* 从redis中获取认证信息
*
* @param request 当前请求
* @return 认证信息
*/
private SecurityContext readSecurityContextFromRedis(HttpServletRequest request) {
if (request == null) {
return null;
}
String nonce = request.getHeader(NONCE_HEADER_NAME);
if (ObjectUtils.isEmpty(nonce)) {
nonce = request.getParameter(NONCE_HEADER_NAME);
if (ObjectUtils.isEmpty(nonce)) {
return null;
}
}
// 根据缓存id获取认证信息
Object o = redisOperator.get((SECURITY_CONTEXT_PREFIX_KEY + nonce));
if (o instanceof SecurityContext context) {
return context;
}
return null;
}
}
5. 将以上自己创建的类添加至security配置中
配置认证服务配置
// 主要是以下两处配置
// 使用redis存储、读取登录的认证信息
http.securityContext(context -> context.securityContextRepository(redisSecurityContextRepository));
// 这里使用自定义的未登录处理,并设置登录地址为前端的登录地址
http
// 当未登录时访问认证端点时重定向至login页面
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
// 这里使用自定义的未登录处理,并设置登录地址为前端的登录地址
new LoginTargetAuthenticationEntryPoint("http://127.0.0.1:5173"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
/**
* 配置端点的过滤器链
*
* @param http spring security核心配置类
* @return 过滤器链
* @throws Exception 抛出
*/
@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,
RegisteredClientRepository registeredClientRepository,
AuthorizationServerSettings authorizationServerSettings) throws Exception {
// 配置默认的设置,忽略认证端点的csrf校验
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
// 新建设备码converter和provider
DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
new DeviceClientAuthenticationConverter(
authorizationServerSettings.getDeviceAuthorizationEndpoint());
DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
new DeviceClientAuthenticationProvider(registeredClientRepository);
// 使用redis存储、读取登录的认证信息
http.securityContext(context -> context.securityContextRepository(redisSecurityContextRepository));
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
// 开启OpenID Connect 1.0协议相关端点
.oidc(Customizer.withDefaults())
// 设置自定义用户确认授权页
.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
// 设置设备码用户验证url(自定义用户验证页)
.deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
deviceAuthorizationEndpoint.verificationUri("/activate")
)
// 设置验证设备码用户确认页面
.deviceVerificationEndpoint(deviceVerificationEndpoint ->
deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
)
.clientAuthentication(clientAuthentication ->
// 客户端认证添加设备码的converter和provider
clientAuthentication
.authenticationConverter(deviceClientAuthenticationConverter)
.authenticationProvider(deviceClientAuthenticationProvider)
);
http
// 当未登录时访问认证端点时重定向至login页面
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginTargetAuthenticationEntryPoint("http://127.0.0.1:5173"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
// 处理使用access token访问用户信息端点和客户端注册端点
.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults()));
// 自定义短信认证登录转换器
SmsCaptchaGrantAuthenticationConverter converter = new SmsCaptchaGrantAuthenticationConverter();
// 自定义短信认证登录认证提供
SmsCaptchaGrantAuthenticationProvider provider = new SmsCaptchaGrantAuthenticationProvider();
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
// 让认证服务器元数据中有自定义的认证方式
.authorizationServerMetadataEndpoint(metadata -> metadata.authorizationServerMetadataCustomizer(customizer -> customizer.grantType(SecurityConstants.GRANT_TYPE_SMS_CODE)))
// 添加自定义grant_type——短信认证登录
.tokenEndpoint(tokenEndpoint -> tokenEndpoint
.accessTokenRequestConverter(converter)
.authenticationProvider(provider));
DefaultSecurityFilterChain build = http.build();
// 从框架中获取provider中所需的bean
OAuth2TokenGenerator tokenGenerator = http.getSharedObject(OAuth2TokenGenerator.class);
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
OAuth2AuthorizationService authorizationService = http.getSharedObject(OAuth2AuthorizationService.class);
// 以上三个bean在build()方法之后调用是因为调用build方法时框架会尝试获取这些类,
// 如果获取不到则初始化一个实例放入SharedObject中,所以要在build方法调用之后获取
// 在通过set方法设置进provider中,但是如果在build方法之后调用authenticationProvider(provider)
// 框架会提示unsupported_grant_type,因为已经初始化完了,在添加就不会生效了
provider.setTokenGenerator(tokenGenerator);
provider.setAuthorizationService(authorizationService);
provider.setAuthenticationManager(authenticationManager);
return build;
}
配置认证相关的过滤器链(资源服务器配置)
配置地方跟上边差不多,自定义登录成功/失败处理,使用redis替换session的存储,因为前后端分离了,还要配置解决跨域问题的过滤器,并禁用cors与csrf。跨域过滤器一定要添加至security配置中,不然只注入ioc中对于security端点不生效!跨域过滤器一定要添加至security配置中,不然只注入ioc中对于security端点不生效!跨域过滤器一定要添加至security配置中,不然只注入ioc中对于security端点不生效!
/**
* 配置认证相关的过滤器链
*
* @param http spring security核心配置类
* @return 过滤器链
* @throws Exception 抛出
*/
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// 添加跨域过滤器
http.addFilter(corsFilter());
// 禁用 csrf 与 cors
http.csrf(AbstractHttpConfigurer::disable);
http.cors(AbstractHttpConfigurer::disable);
http.authorizeHttpRequests((authorize) -> authorize
// 放行静态资源
.requestMatchers("/assets/**", "/webjars/**", "/login", "/getCaptcha", "/getSmsCaptcha").permitAll()
.anyRequest().authenticated()
)
// 指定登录页面
.formLogin(formLogin ->
formLogin.loginPage("/login")
// 登录成功和失败改为写回json,不重定向了
.successHandler(new LoginSuccessHandler())
.failureHandler(new LoginFailureHandler())
);
// 添加BearerTokenAuthenticationFilter,将认证服务当做一个资源服务,解析请求头中的token
http.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults())
.accessDeniedHandler(SecurityUtils::exceptionHandler)
.authenticationEntryPoint(SecurityUtils::exceptionHandler)
);
http
// 当未登录时访问认证端点时重定向至login页面
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginTargetAuthenticationEntryPoint("http://127.0.0.1:5173"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
);
// 使用redis存储、读取登录的认证信息
http.securityContext(context -> context.securityContextRepository(redisSecurityContextRepository));
return http.build();
}
在AuthorizationConfig中添加跨域过滤器
/**
* 跨域过滤器配置
*
* @return CorsFilter
*/
@Bean
public CorsFilter corsFilter() {
// 初始化cors配置对象
CorsConfiguration configuration = new CorsConfiguration();
// 设置允许跨域的域名,如果允许携带cookie的话,路径就不能写*号, *表示所有的域名都可以跨域访问
configuration.addAllowedOrigin("http://127.0.0.1:5173");
// 设置跨域访问可以携带cookie
configuration.setAllowCredentials(true);
// 允许所有的请求方法 ==> GET POST PUT Delete
configuration.addAllowedMethod("*");
// 允许携带任何头信息
configuration.addAllowedHeader("*");
// 初始化cors配置源对象
UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
// 给配置源对象设置过滤的参数
// 参数一: 过滤的路径 == > 所有的路径都要求校验是否跨域
// 参数二: 配置类
configurationSource.registerCorsConfiguration("/**", configuration);
// 返回配置好的过滤器
return new CorsFilter(configurationSource);
}
RedisConstants中添加常量
/**
* 认证信息存储前缀
*/
public static final String SECURITY_CONTEXT_PREFIX_KEY = "security_context:";
如果没有RedisOperator
可以看下我之前的优化篇
完整的AuthorizationConfig如下
package com.example.config;
import com.example.authorization.device.DeviceClientAuthenticationConverter;
import com.example.authorization.device.DeviceClientAuthenticationProvider;
import com.example.authorization.handler.LoginFailureHandler;
import com.example.authorization.handler.LoginSuccessHandler;
import com.example.authorization.handler.LoginTargetAuthenticationEntryPoint;
import com.example.authorization.sms.SmsCaptchaGrantAuthenticationConverter;
import com.example.authorization.sms.SmsCaptchaGrantAuthenticationProvider;
import com.example.constant.SecurityConstants;
import com.example.support.RedisSecurityContextRepository;
import com.example.util.SecurityUtils;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.*;
import java.util.stream.Collectors;
/**
* 认证配置
* {@link EnableMethodSecurity} 开启全局方法认证,启用JSR250注解支持,启用注解 {@link Secured} 支持,
* 在Spring Security 6.0版本中将@Configuration注解从@EnableWebSecurity, @EnableMethodSecurity, @EnableGlobalMethodSecurity
* 和 @EnableGlobalAuthentication 中移除,使用这些注解需手动添加 @Configuration 注解
* {@link EnableWebSecurity} 注解有两个作用:
* 1. 加载了WebSecurityConfiguration配置类, 配置安全认证策略。
* 2. 加载了AuthenticationConfiguration, 配置了认证信息。
*
* @author vains
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true)
public class AuthorizationConfig {
private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
private final RedisSecurityContextRepository redisSecurityContextRepository;
/**
* 配置端点的过滤器链
*
* @param http spring security核心配置类
* @return 过滤器链
* @throws Exception 抛出
*/
@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,
RegisteredClientRepository registeredClientRepository,
AuthorizationServerSettings authorizationServerSettings) throws Exception {
// 配置默认的设置,忽略认证端点的csrf校验
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
// 新建设备码converter和provider
DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
new DeviceClientAuthenticationConverter(
authorizationServerSettings.getDeviceAuthorizationEndpoint());
DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
new DeviceClientAuthenticationProvider(registeredClientRepository);
// 使用redis存储、读取登录的认证信息
http.securityContext(context -> context.securityContextRepository(redisSecurityContextRepository));
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
// 开启OpenID Connect 1.0协议相关端点
.oidc(Customizer.withDefaults())
// 设置自定义用户确认授权页
.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
// 设置设备码用户验证url(自定义用户验证页)
.deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
deviceAuthorizationEndpoint.verificationUri("/activate")
)
// 设置验证设备码用户确认页面
.deviceVerificationEndpoint(deviceVerificationEndpoint ->
deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
)
.clientAuthentication(clientAuthentication ->
// 客户端认证添加设备码的converter和provider
clientAuthentication
.authenticationConverter(deviceClientAuthenticationConverter)
.authenticationProvider(deviceClientAuthenticationProvider)
);
http
// 当未登录时访问认证端点时重定向至login页面
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginTargetAuthenticationEntryPoint("http://127.0.0.1:5173"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
// 处理使用access token访问用户信息端点和客户端注册端点
.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults()));
// 自定义短信认证登录转换器
SmsCaptchaGrantAuthenticationConverter converter = new SmsCaptchaGrantAuthenticationConverter();
// 自定义短信认证登录认证提供
SmsCaptchaGrantAuthenticationProvider provider = new SmsCaptchaGrantAuthenticationProvider();
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
// 让认证服务器元数据中有自定义的认证方式
.authorizationServerMetadataEndpoint(metadata -> metadata.authorizationServerMetadataCustomizer(customizer -> customizer.grantType(SecurityConstants.GRANT_TYPE_SMS_CODE)))
// 添加自定义grant_type——短信认证登录
.tokenEndpoint(tokenEndpoint -> tokenEndpoint
.accessTokenRequestConverter(converter)
.authenticationProvider(provider));
DefaultSecurityFilterChain build = http.build();
// 从框架中获取provider中所需的bean
OAuth2TokenGenerator tokenGenerator = http.getSharedObject(OAuth2TokenGenerator.class);
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
OAuth2AuthorizationService authorizationService = http.getSharedObject(OAuth2AuthorizationService.class);
// 以上三个bean在build()方法之后调用是因为调用build方法时框架会尝试获取这些类,
// 如果获取不到则初始化一个实例放入SharedObject中,所以要在build方法调用之后获取
// 在通过set方法设置进provider中,但是如果在build方法之后调用authenticationProvider(provider)
// 框架会提示unsupported_grant_type,因为已经初始化完了,在添加就不会生效了
provider.setTokenGenerator(tokenGenerator);
provider.setAuthorizationService(authorizationService);
provider.setAuthenticationManager(authenticationManager);
return build;
}
/**
* 配置认证相关的过滤器链
*
* @param http spring security核心配置类
* @return 过滤器链
* @throws Exception 抛出
*/
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// 添加跨域过滤器
http.addFilter(corsFilter());
// 禁用 csrf 与 cors
http.csrf(AbstractHttpConfigurer::disable);
http.cors(AbstractHttpConfigurer::disable);
http.authorizeHttpRequests((authorize) -> authorize
// 放行静态资源
.requestMatchers("/assets/**", "/webjars/**", "/login", "/getCaptcha", "/getSmsCaptcha").permitAll()
.anyRequest().authenticated()
)
// 指定登录页面
.formLogin(formLogin ->
formLogin.loginPage("/login")
// 登录成功和失败改为写回json,不重定向了
.successHandler(new LoginSuccessHandler())
.failureHandler(new LoginFailureHandler())
);
// 添加BearerTokenAuthenticationFilter,将认证服务当做一个资源服务,解析请求头中的token
http.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt(Customizer.withDefaults())
.accessDeniedHandler(SecurityUtils::exceptionHandler)
.authenticationEntryPoint(SecurityUtils::exceptionHandler)
);
http
// 当未登录时访问认证端点时重定向至login页面
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginTargetAuthenticationEntryPoint("http://127.0.0.1:5173"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
);
// 使用redis存储、读取登录的认证信息
http.securityContext(context -> context.securityContextRepository(redisSecurityContextRepository));
return http.build();
}
/**
* 跨域过滤器配置
*
* @return CorsFilter
*/
@Bean
public CorsFilter corsFilter() {
// 初始化cors配置对象
CorsConfiguration configuration = new CorsConfiguration();
// 设置允许跨域的域名,如果允许携带cookie的话,路径就不能写*号, *表示所有的域名都可以跨域访问
configuration.addAllowedOrigin("http://127.0.0.1:5173");
// 设置跨域访问可以携带cookie
configuration.setAllowCredentials(true);
// 允许所有的请求方法 ==> GET POST PUT Delete
configuration.addAllowedMethod("*");
// 允许携带任何头信息
configuration.addAllowedHeader("*");
// 初始化cors配置源对象
UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
// 给配置源对象设置过滤的参数
// 参数一: 过滤的路径 == > 所有的路径都要求校验是否跨域
// 参数二: 配置类
configurationSource.registerCorsConfiguration("/**", configuration);
// 返回配置好的过滤器
return new CorsFilter(configurationSource);
}
/**
* 自定义jwt,将权限信息放至jwt中
*
* @return OAuth2TokenCustomizer的实例
*/
@Bean
public OAuth2TokenCustomizer oAuth2TokenCustomizer() {
return context -> {
// 检查登录用户信息是不是UserDetails,排除掉没有用户参与的流程
if (context.getPrincipal().getPrincipal() instanceof UserDetails user) {
// 获取申请的scopes
Set scopes = context.getAuthorizedScopes();
// 获取用户的权限
Collection