之前讲解过一篇SpringSecurity定制化流程的方案,很多人说看起来比较复杂,这次对其中流程优化部分,顺便记录下最新的理解心得。
由于是优化,肯定是基于之前的代码,因此有些地方不清楚的可以参考上篇文章:SpringSecurity框架——认证流程介绍,实战代码
与全定制化的区别:
- 上文提到自定义UsernamePasswordAuthenticationFilter过滤器,重写attemptAuthentication()方法,通常这里会在这里对验证码,请求方法类型等进行合法性校验。这次以内联的形式:将依赖中的类,以相同的包路径在项目中创建一份,可以理解为替换Security框架中的UsernamePasswordAuthenticationFilter过滤器,实现方式:将jar中的filter复制一份到项目中,按照业务需求重新attemptAuthentication()方法。
-
这里对上文自定义的AbstractAuthenticationToken和AbstractUserDetailsAuthenticationProvider均抛弃掉,AbstractUserDetailsAuthenticationProvider使用内联的方式进行替换,AbstractAuthenticationToken使用默认的,如果是无特殊需求,默认的两个字段可以满足密码、手机号登录等模式,若为第三方授权登录需要三个以上登录信息字段,则可以去单独定制化token
-
最后通过token的类型做认证方法的转发的入口,this.getAuthenticationManager().authenticate(authRequest)。这里只做了一种默认的token类型,如果需要定制化则在这里返回定制化的token类型,因为最终在AuthenticationProvider中会通过suppors方法进行匹配,通过类型匹配就会进入相对应的认证方法流程。具体逻辑可以参考上文定制化FIlter中Authentication对象流程详解。
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
UsernamePasswordAuthenticationToken authRequest;
String contentType = request.getHeader(HttpHeaders.CONTENT_TYPE);
if (MediaType.APPLICATION_JSON_VALUE.equalsIgnoreCase(contentType)) {
Map<String, String> authenticationBean = JsonUtil.defaultObjectMapper().readValue(request.getInputStream(), new TypeReference<Map<String, String>>() {
});
String username = authenticationBean.getOrDefault(this.usernameParameter, "").trim();
String password = authenticationBean.getOrDefault(this.passwordParameter, "").trim();
authRequest = new UsernamePasswordAuthenticationToken(username, password);
} else {
String username = obtainUsername(request);
username = (username != null) ? username : "";
username = username.trim();
String password = obtainPassword(request);
password = (password != null) ? password : "";
authRequest = new UsernamePasswordAuthenticationToken(username, password);
}
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
- AbstractUserDetailsAuthenticationProvider使用内联的方式,不改动上文的代码方法体逻辑,可以理解为是之前自定义的Provider重命名了。
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Assert.isInstanceOf(CustomAuthenticationToken.class, authentication,
() -> messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only CustomAuthenticationToken is supported"))
UserDetails user = this.userCache.getUserFromCache(authJson);
if (user == null) {
try {
// 调用retrieveUser 就是做了一层转发 这里会抓取抛出的异常,对异常进行重新抛出,目的是对应失败处理器的异常类型
user = retrieveUser(authJson, (CustomAuthenticationToken) authentication);
} catch (UsernameNotFoundException notFound) {
logger.debug("Identity '" + loginName + "' not found");
if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
} else {
throw notFound;
}
}
Assert.notNull(user,
"retrieveUser returned null - a violation of the interface contract");
}
try {
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (CustomAuthenticationToken) authentication);
} catch (Exception exception) {
if (cacheWasUsed) {
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
// 查询到用户
user = retrieveUser(authJson, (CustomAuthenticationToken) authentication);
preAuthenticationChecks.check(user);
// 查询到用户后 检测存储的密码和提交的密码是否相同,否则抛出异常
additionalAuthenticationChecks(user, (CustomAuthenticationToken) authentication);
} else {
throw exception;
}
}
// 这里是返回了一个认证过的自定义token
// 自定义的token会有一个属性: Boolean Authenticated; 走到这里会创建一个token对象并将这个属性设为true
return CustomAuthenticationToken(platform, organId, principalToReturn, authentication, user);
}
// 这里需要注意,在用户注册时的密码加密必须要使用passwordEncoder.encode()方法
@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
CustomAdminUsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}
@Override
protected final UserDetails retrieveUser(String authJson,
CustomHealthUsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(authJson);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
- token不做展示了,使用的框架中默认类型
- UserDetailsService的实现这次继承UserDetailsManager,原因是UserDetailsService是顶级父类,它只定义了一个抽象方法loadUserByUsername(String username),而UserDetailsManager所定义的抽象方法更加完善,包含了创建新用户 和修改密码等,易于扩展比如在登录后修改密码。代码方法体方面与上文相较是不变的,不再过多展示。
// 源码
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
// 源码
public interface UserDetailsManager extends UserDetailsService {
/**
* Create a new user with the supplied details.
*/
void createUser(UserDetails user);
/**
* Update the specified user.
*/
void updateUser(UserDetails user);
/**
* Remove the user with the given login name from the system.
*/
void deleteUser(String username);
/**
* Modify the current user's password. This should change the user's password in the
* persistent user repository (datbase, LDAP etc).
* @param oldPassword current password (for re-authentication if required)
* @param newPassword the password to change to
*/
void changePassword(String oldPassword, String newPassword);
/**
* Check if a user with the supplied login name exists in the system.
*/
boolean userExists(String username);
}
重点讲一下manager的实现类,业务逻辑无关紧要就删除了,重点是它的应用和注解,在上文提到是由WebSecurityConfigurerAdapter中方法注册定制化的provider,在provider的属性中注入定制化的UserDetailsService,而这里使用内联形式是如何被发现并注册进provider中的。
一共有两点
- 首先是注解,@Service、@Primary两个注解分别作用是交给spring去管理、作为同类型首先使用需要UserDetailsService的类型时,默认注入带有@Primary的类。
- 第二点是在项目启动初始化时,InitializeUserDetailsBeanManagerConfigurer对manager的管理加载机制是通过类型获取,因此与@Primary注解相互作用下,项目会把定制化的SecurityUserDetailsManager优先注入provider中去。
@Primary
@Service
public class SecurityUserDetailsManager implements UserDetailsManager {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
@Override
public void createUser(UserDetails user) {
}
@Override
public void updateUser(UserDetails user) {
}
@Override
public void deleteUser(String username) {
}
@Override
public void changePassword(String oldPassword, String newPassword) {
}
@Override
public boolean userExists(String username) {
return false;
}
}
// 源码
@Order(InitializeUserDetailsBeanManagerConfigurer.DEFAULT_ORDER)
class InitializeUserDetailsBeanManagerConfigurer extends GlobalAuthenticationConfigurerAdapter {
static final int DEFAULT_ORDER = Ordered.LOWEST_PRECEDENCE - 5000;
private final ApplicationContext context;
/**
* @param context
*/
InitializeUserDetailsBeanManagerConfigurer(ApplicationContext context) {
this.context = context;
}
@Override
public void init(AuthenticationManagerBuilder auth) throws Exception {
auth.apply(new InitializeUserDetailsManagerConfigurer());
}
class InitializeUserDetailsManagerConfigurer extends GlobalAuthenticationConfigurerAdapter {
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
if (auth.isConfigured()) {
return;
}
UserDetailsService userDetailsService = getBeanOrNull(UserDetailsService.class);
if (userDetailsService == null) {
return;
}
PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class);
UserDetailsPasswordService passwordManager = getBeanOrNull(UserDetailsPasswordService.class);
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
if (passwordEncoder != null) {
provider.setPasswordEncoder(passwordEncoder);
}
if (passwordManager != null) {
provider.setUserDetailsPasswordService(passwordManager);
}
provider.afterPropertiesSet();
auth.authenticationProvider(provider);
}
/**
* @return a bean of the requested class if there's just a single registered
* component, null otherwise.
*/
private <T> T getBeanOrNull(Class<T> type) {
String[] beanNames = InitializeUserDetailsBeanManagerConfigurer.this.context.getBeanNamesForType(type);
if (beanNames.length != 1) {
return null;
}
return InitializeUserDetailsBeanManagerConfigurer.this.context.getBean(beanNames[0], type);
}
}
}
- 最重要的一点来了,对流程的配置。上文提到继承WebSecurityConfigurerAdapter然后重写configure()方法,目的是为了配置登录的一些定制化配置,并且加入了定制化的provider等认证流程。这次也换了一种形式使用,并且部分定制、部分内联的方式,是如何启用的呢?下面结合代码分析:
主要起作用的是注解:@EnableWebSecurity两个作用,1: 加载了WebSecurityConfiguration配置类,注入了一个非常重要的bean, bean的name为: springSecurityFilterChain,配置安全认证策略。2: 加载了AuthenticationConfiguration, 配置了认证信息,启用了自定义的manager。
内联不用配置即可生效,因为内联本就是覆盖依赖中的同包路径下同名的文件,定制的一些类则是在内联中的方法调用时的规则上动手脚。
/**
将此注释添加到 @Configuration 类以在任何 WebSecurityConfigurer 中定义 Spring Security 配置,或者更可能通过扩展 WebSecurityConfigurerAdapter 基类并覆盖各个方法:
@Configuration
@EnableWebSecurity
公共类 MyWebSecurityConfiguration 扩展 WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) 抛出异常{
网络忽略()
// Spring Security 应该完全忽略以 /resources/ 开头的 URL
.antMatchers("/资源/**");
}
@Override
受保护的无效配置(HttpSecurity http)抛出异常{
http.authorizeRequests().antMatchers("/public/**").permitAll().anyRequest()
.hasRole("用户").and()
// 可能更多的配置...
.formLogin() // 启用基于表单的登录
// 为所有与表单登录关联的 URL 设置 permitAll
.permitAll();
}
@Override
受保护的无效配置(AuthenticationManagerBuilder auth)抛出异常{
授权
// 使用名为“user”和“admin”的用户启用基于内存的身份验证
.inMemoryAuthentication().withUser("用户").password("密码").roles("USER")
.and().withUser("admin").password("密码").roles("USER", "ADMIN");
}
// 可能有更多重写的方法...
}
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import({ WebSecurityConfiguration.class, SpringWebMvcImportSelector.class, OAuth2ImportSelector.class,
HttpSecurityConfiguration.class })
@EnableGlobalAuthentication
@Configuration
public @interface EnableWebSecurity {
/**
* Controls debugging support for Spring Security. Default is false.
* @return if true, enables debug support with Spring Security
*/
boolean debug() default false;
}
@RequiredArgsConstructor
@EnableWebSecurity
public class AuthenticationServerConfig {
/**
* {@link SecurityBeanConfig#corsConfigurationSource()}
*/
// 注入跨域配置对象
private final CorsConfigurationSource corsConfigurationSource;
// 注入权限校验配置对象
private final CustomSecurityExpressionRoot customSecurityExpressionRoot;
// 注入验证码定制化配置对象
private final ValidateCodeRepository validateCodeRepository;
@Bean
// 定义接口白名单
WebSecurityCustomizer ignoringCustomizer() {
String[] swaggerExportPaths = {
"/doc.html", "/index.html", "/swagger-ui.html",
"/api-docs-ext", "/swagger-resources", "/api-docs", "/v2/api-docs-ext", "/v2/api-docs",
"/swagger-resources/configuration/ui", "/swagger-resources/configuration/security",
"/manifest.json", "/robots.txt", "/favicon.ico",
"/webjars/css/chunk-*.css", "/webjars/css/app.*.css",
"/webjars/js/app.*.js", "/webjars/js/chunk-*.js", "/precache-manifest.*.js", "/service-worker.js",
};
String[] actuatorPaths = {
"/actuator/health",
"/actuator/prometheus"
};
return (web) -> web.ignoring().antMatchers(swaggerExportPaths).antMatchers(actuatorPaths);
}
@Bean
// 这里对应上文重写configure()的方法体,已bean的形式定制化后交给spring
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.csrf()
.disable()
.cors()
.configurationSource(corsConfigurationSource);
http
.exceptionHandling()
.accessDeniedHandler(new DefaultAccessDeniedHandler())
.authenticationEntryPoint(new DefaultAuthenticationEntryPoint());
http
.logout()
.clearAuthentication(true)
.logoutUrl(SecurityConstants.URI.URL_AUTH_LOGOUT)
.logoutSuccessHandler(new DefaultLogoutSuccessHandler());
http
.formLogin()
// .loginPage("http://127.0.0.1:3001/#/login")
.loginProcessingUrl(SecurityConstants.URI.URL_LOGIN_PROCESSING)
.successHandler(new CustomAuthenticationSuccessHandler())
.failureHandler(new DefaultAuthenticationFailureHandler());
// http.authenticationProvider(new DaoAuthenticationProvider());
// 验证码配置
http.apply(new ValidateCodeConfigurer<>())
.failureHandler(new DefaultAuthenticationFailureHandler())
.validateCodeGenerator(SecurityConstants.URI.URL_IMAGE_CAPTCHA, new ImageCodeGenerator(validateCodeRepository).setExpireIn(60 * 3))
// .validateCodeGenerator("/auth/captcha/mobile", new SmsCodeGenerator(validateCodeRepository).setNeedAuthenticated(true))
.validateCodeProcessor(SecurityConstants.URI.URL_LOGIN_PROCESSING, new DefaultValidateCodeProcessor(validateCodeRepository))
.and()
;
http.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> {
authorizationManagerRequestMatcherRegistry
.antMatchers(SecurityConstants.URI.URL_IMAGE_CAPTCHA,"/event/evaluation/**","/openapi/**","/bw/**")
.permitAll()
.anyRequest()
.access(customSecurityExpressionRoot);
});
// sessionManagement
http.sessionManagement(httpSecuritySessionManagementConfigurer -> {
httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
httpSecuritySessionManagementConfigurer.sessionConcurrency(concurrencyControlConfigurer -> {
// 触发创建 ConcurrentSessionFilter
concurrencyControlConfigurer.maximumSessions(3);
concurrencyControlConfigurer.expiredSessionStrategy(event -> {
HttpServletResponse response = event.getResponse();
// SessionInformation sessionInformation = event.getSessionInformation();
response.setStatus(HttpStatus.UNAUTHORIZED.value());
// 构建项目统一接口响应对象
Rest<BaseResponse> rest = Rest.error(AuthenticationErrorCodeEnum.LOGIN_ON_ANOTHER_DEVICE, "本次登录失效");
HttpContextUtil.write(response, rest);
});
httpSecuritySessionManagementConfigurer.invalidSessionStrategy((request, response) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
// 构建项目统一接口响应对象
HttpContextUtil.write(response, Rest.error(AuthenticationErrorCodeEnum.CREDENTIALS_EXPIRED));
});
});
});
// @formatter:on
return http.build();
}
}