前提:本文的重点是从实践的角度浅析Spring Security的实现原理。

1. Security 配置文件

SpringSecurityConfig

通过继承【WebSecurityConfigurerAdapter】类实现对于特定权限拦截的配置,具体的常用配置如下配置文件注释。

在系统接口中要自定义放行的URL可以通过配置的【getAnonymousUrl】获取具有特定注解的URL,并添加到配置的放行白名单中即可。

/**
 * SpringSecurity 配置
 *
 * @author alexsun1021@163.com
 * @date 2021/12/29 17:24
 */
@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity // 开启Security
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) // 保证Post之前的注解可用
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    // 要提前注入容器
    private final CorsFilter corsFilter;
    // Spring Context
    private final ApplicationContext applicationContext;
    // 权限拦截处理点
    private final JwtAuthenticationEntryPoint authenticationEntryPoint;
    // 权限处理Handler
    private final JwtAccessDeniedHandler accessDeniedHandler;
    // Jwt处理权限配置
    private final JwtAuthorizationConfigurer jwtAuthorizationConfigurer;

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 密码加密方式
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        log.info("初始化 -> 自定义[{}]", "SpringSecurityConfig");
        // 搜寻匿名标记 url: @AnonymousAccess
        RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) applicationContext.getBean("requestMappingHandlerMapping");
        Map<RequestMappingInfo, HandlerMethod> handlerMethods = requestMappingHandlerMapping.getHandlerMethods();
        // 获取被@AnonymousAccess注解的方法请求URL
        List<String> anonymousUrlList = this.getAnonymousUrl(handlerMethods);
        http
                // 禁用 Spring Security 自带的跨域处理CSRF 防止csrf攻击
                .csrf().disable()
                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)

                // 未经过授权可进行的操作
                .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler)

                // 因为使用jwt托管安全信息,所以把Session禁止掉
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                // 自定放行策略
                .and()
                .authorizeRequests()
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js",
                        "/webSocket/**"
                ).permitAll()      // 静态资源
                // swagger 文档
                .antMatchers("/swagger-ui.html").permitAll()
                .antMatchers("swagger-resources/**").permitAll()
                .antMatchers("webjars/**").permitAll()
                .antMatchers("/*/api-docs").permitAll()
                // 前端过来的第一次验证请求,放行 提高通讯效率
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 所有类型的接口都放行
                .antMatchers(anonymousUrlList.toArray(new String[0])).permitAll()
                // 所有请求都需要认证
                .anyRequest().authenticated()
                .and().apply(jwtAuthorizationConfigurer);
                //apply(jwtAuthorizationConfigurer) 可替换使用如下方式直接注入过滤器
                //http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

    }
}

JwtAuthenticationEntryPoint

该类实现接口【AuthenticationEntryPoint】用于处理当无凭证访问系统API资源时的处理逻辑。

Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest,
                         HttpServletResponse httpServletResponse,
                         AuthenticationException e) throws IOException {
        // 当用户尝试访问安全的REST资源而不提供任何凭据时,将调用此方法发送401 响应
        log.info("JwtAuthenticationEntryPoint....");
        httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED,null==e?"Unauthorized":e.getMessage());
    }
}

JwtAccessDeniedHandler

该类实现接口【AccessDeniedHandler】用于处理当用户没有授权时访问系统REST资源时的处理逻辑。

@Slf4j
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest httpServletRequest,
                       HttpServletResponse httpServletResponse,
                       AccessDeniedException e) throws IOException {
        log.info("JwtAccessDeniedHandler....");
        //当用户在没有授权的情况下访问受保护的REST资源时,将调用此方法发送403 Forbidden响应
        httpServletResponse.sendError(HttpServletResponse.SC_FORBIDDEN, e.getMessage());
    }
}

JwtAuthorizationConfigurer

该类继承自SecurityConfigurer的基类【SecurityConfigurerAdapter】的,通过重载基类中的方法实现更丰富的配置,此处重载【configure】方法对HttpSecurity进行Filter的配置。此处完全可以直接在此处直接配置自定义的Filter。

@Component
@RequiredArgsConstructor
public class JwtAuthorizationConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private final TokenProvider tokenProvider;
    private final MySecurityProperties properties;
    private final IOnlineUserService onlineUserService;
    private final IUserCacheClean userCacheClean;

    @Override
    public void configure(HttpSecurity http) {
        JwtAuthorizationTokenFilter customFilter = new JwtAuthorizationTokenFilter(tokenProvider, properties, onlineUserService, userCacheClean);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

JwtAuthorizationTokenFilter

该类用于拦截每次请求进行鉴权处理,判断程序是否可以继续进行,具体视业务而定,此处则用于处理在线的用户token,查库关联数据库处理用户鉴权的操作则通过实现【UserDetailsService】接口的[loadUserByUsername]方法,此处以后再说。

@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationTokenFilter extends GenericFilterBean {

    // token生产者
    private final TokenProvider tokenProvider;
    // yml配置类
    private final MySecurityProperties mySecurityProperties;
    private final IOnlineUserService onlineUserService;
    private final IUserCacheClean userCacheClean;


    @Override
    public void doFilter(ServletRequest servletRequest,
                         ServletResponse servletResponse,
                         FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String token = this.resolveToken(httpServletRequest);
        // 如果token为空则不处理
        if (StrUtil.isNotBlank(token)) {
            OnlineUserDto onlineUserDto = null;
            boolean cleanUserCache = false;
            try {
                onlineUserDto = onlineUserService.getOne(mySecurityProperties.getOnlineKey() + token);
            } catch (ExpiredJwtException ex) {
                log.error(ex.getMessage());
                cleanUserCache = true;
            } finally {
                if (cleanUserCache || Objects.isNull(onlineUserDto)) {
                    userCacheClean.cleanUserCache(String.valueOf(tokenProvider.getClaims(token).get(TokenProvider.AUTHORITIES_KEY)));
                }
            }
            if (onlineUserDto != null && StringUtils.hasText(token)) {
                Authentication authentication = tokenProvider.getAuthentication(token);
                // 存储认证成功的用户登录信息
                SecurityContextHolder.getContext().setAuthentication(authentication);
                // Token 续期
                tokenProvider.checkRenewal(token);
            }
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }

    /**
     * 处理token
     *
     * @param httpServletRequest /
     * @return /
     */
    private String resolveToken(HttpServletRequest httpServletRequest) {
        String bearerToken = httpServletRequest.getHeader(mySecurityProperties.getHeader());
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(mySecurityProperties.getTokenStartWith())) {
            // 去掉令牌前缀
            return bearerToken.replace(mySecurityProperties.getTokenStartWith(), "");
        } else {
            return null;
        }
    }
}

至此,Spring Security的配置部分到此结束。下面将介绍该框架是如何整合业务实现拦截的。

2. 浅析授权鉴权拦截原理

实现代码,上文提到要实现【UserDetailsService】接口的【loadUserByUsername】的方法来进行鉴权,即获得访问系统的权限。

本质上来说是通过实现loadUserByUsername方法返回Security鉴权所需要的[UserDetails]实例和用户输入的用户名及密码构造的[UsernamePasswordAuthenticationToken]实例作对比校验。

更多复杂的业务自行添加即可。如下:

UserDetailsServiceImpl

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private ITbUserService userService;

    /**
     * 获取登录UserDetails
     * JwtUserDetails 是 UserDetails的子类
     *
     * @param username /
     * @return Security UserDetails 实例 用于校验
     * @throws UsernameNotFoundException /
     */
    @Override
    public JwtUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        TbUser tbUser = userService.selectByLoginName(username);
        if (Objects.isNull(tbUser) || !tbUser.getEnabled()) {
            throw new UsernameNotFoundException("用户不存在");
        }
        List<GrantedAuthority> authorityList = new ArrayList<>();
        authorityList.add(new SimpleGrantedAuthority("ROLE_USER"));
        return JwtUserDetails.builder()
                .user(tbUser)
                .authorities(authorityList)
                .build();
    }
}

登录鉴权

【login】

@ApiOperation(value = "鉴权登录", httpMethod = "GET")
    @AnonymousGetMapping("/login")
    public ApiResult<Map<String, Object>> login(@Validated @RequestBody AuthUserReq authUser
            , HttpServletRequest request) throws Exception {
        // 1. 鉴权开始
        // 使用私钥解析密码
        String password = RsaUtils.decryptByPrivateKey(rsaProperties.getPrivateKey(), authUser.getPassword());
        // 根据用户名和密码创建Security校验实体
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(authUser.getUsername(), password);
        // 实质调用[UserDetailsService]的实现类进行连接数据库生成[UserDetails]的实例,与[authToken]进行校验。
        // 具体见[UserDetailsServiceImpl]
        Authentication authentication = authManagerBuilder.getObject().authenticate(authToken);
        // 鉴权结果放入当前线程中
        SecurityContextHolder.getContext().setAuthentication(authentication); 
        // 2. 生成token
        String token = tokenProvider.createToken(authentication);
        final JwtUserDetails jwtUserDetails = (JwtUserDetails) authentication.getPrincipal();
        // 3. 保存在线信息
        onlineUserService.save(jwtUserDetails, token, request);
        // 4. 返回 TOKEN 与 用户信息
        Map<String, Object> retMap = new HashMap<>(4);
        retMap.put("token", mySecurityProperties.getTokenStartWith() + token);
        retMap.put("user", jwtUserDetails);
        return ApiResult.okData(retMap);
    }

2.1 鉴权流程

Spring Security中进行身份验证的是【AuthenticationManager】接口,【ProviderManager】是这个接口的一个默认实现,但是这个实现并不自己来实现身份验证,而是委托给接口【AuthenticationProvider】去实现,而集成了并实现了这个委托接口的类是【AbstractUserDetailsAuthenticationProvider】,而最终的核心校验则是通过继承了该类的【DaoAuthenticationProvider】去实现的。

类关系如图所示:

spring 白名单 api spring security 白名单规则_spring 白名单 api

 

可能这么说还是有点懵,我们通过下面跟代码

// 1. 根据用户名和密码创建Security校验实体
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(authUser.getUsername(), password);
// 2.实质调用[UserDetailsService]的实现类进行连接数据库生成[UserDetails]的实例,与[authToken]进行校验。具体见[UserDetailsServiceImpl]
        Authentication authentication = authManagerBuilder.getObject().authenticate(authToken);

// 主要跟第2步 authManagerBuilder.getObject() 实质获取的是 AuthenticationManager 的实例

[AuthenticationManager]-源码

public interface AuthenticationManager {
	Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

[ProviderManager]     --- AuthenticationManager的实现

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
// 省略其它源码
@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		AuthenticationException parentException = null;
		Authentication result = null;
		Authentication parentResult = null;
		int currentPosition = 0;
		int size = this.providers.size();
        // 委托给 AuthenticationProvider 去实现身份认证
		for (AuthenticationProvider provider : getProviders()) {
			if (!provider.supports(toTest)) {
				continue;
			}
			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
						provider.getClass().getSimpleName(), ++currentPosition, size));
			}
			try {
                // 此处为调用身份认证方法
				result = provider.authenticate(authentication);
				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException | InternalAuthenticationServiceException ex) {
				prepareException(ex, authentication);
				// SEC-546: Avoid polling additional providers if auth failure is due to
				// invalid account status
				throw ex;
			}
			catch (AuthenticationException ex) {
				lastException = ex;
			}
		}
		if (result == null && this.parent != null) {
			// Allow the parent to try.
			try {
				parentResult = this.parent.authenticate(authentication);
				result = parentResult;
			}
			catch (ProviderNotFoundException ex) {
				// ignore as we will throw below if no other exception occurred prior to
				// calling parent and the parent
				// may throw ProviderNotFound even though a provider in the child already
				// handled the request
			}
			catch (AuthenticationException ex) {
				parentException = ex;
				lastException = ex;
			}
		}
		if (result != null) {
			if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
				// Authentication is complete. Remove credentials and other secret data
				// from authentication
				((CredentialsContainer) result).eraseCredentials();
			}
			// If the parent AuthenticationManager was attempted and successful then it
			// will publish an AuthenticationSuccessEvent
			// This check prevents a duplicate AuthenticationSuccessEvent if the parent
			// AuthenticationManager already published it
			if (parentResult == null) {
				this.eventPublisher.publishAuthenticationSuccess(result);
			}

			return result;
		}

		// Parent was null, or didn't authenticate (or throw an exception).
		if (lastException == null) {
			lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound",
					new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}"));
		}
		// If the parent AuthenticationManager was attempted and failed then it will
		// publish an AbstractAuthenticationFailureEvent
		// This check prevents a duplicate AbstractAuthenticationFailureEvent if the
		// parent AuthenticationManager already published it
		if (parentException == null) {
			prepareException(lastException, authentication);
		}
		throw lastException;
	}

}

[AuthenticationProvider]  --- 受委托的类

public interface AuthenticationProvider {

	/**
	 * Performs authentication with the same contract as
	 * {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)}
	 * .
	 * @param authentication the authentication request object.
	 * @return a fully authenticated object including credentials. May return
	 * <code>null</code> if the <code>AuthenticationProvider</code> is unable to support
	 * authentication of the passed <code>Authentication</code> object. In such a case,
	 * the next <code>AuthenticationProvider</code> that supports the presented
	 * <code>Authentication</code> class will be tried.
	 * @throws AuthenticationException if authentication fails.
	 */
	Authentication authenticate(Authentication authentication) throws AuthenticationException;

	boolean supports(Class<?> authentication);

}

[AbstractUserDetailsAuthenticationProvider] --- 实现了受委托的类的方法

public abstract class AbstractUserDetailsAuthenticationProvider
		implements AuthenticationProvider, InitializingBean, MessageSourceAware {
// 省略其它源码
@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
				() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
						"Only UsernamePasswordAuthenticationToken is supported"));
		String username = determineUsername(authentication);
		boolean cacheWasUsed = true;
		UserDetails user = this.userCache.getUserFromCache(username);
		if (user == null) {
			cacheWasUsed = false;
			try {
				user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
			}
			catch (UsernameNotFoundException ex) {
				this.logger.debug("Failed to find user '" + username + "'");
				if (!this.hideUserNotFoundExceptions) {
					throw ex;
				}
				throw new BadCredentialsException(this.messages
						.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
			}
			Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
		}
		try {
			this.preAuthenticationChecks.check(user);
// 此处调取真正的身份校验方法进行校验,由此也可以看出实质的身份校验需要的入参为
// UserDetails:用户登录鉴权,数据库查询所得,即所实现的UserDetailsService接口loadUserByUsername方法返回
// UsernamePasswordAuthenticationToken:用户登录输入的用户名和密码
			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
		}
		catch (AuthenticationException ex) {
			if (!cacheWasUsed) {
				throw ex;
			}
			// There was a problem, so try again after checking
			// we're using latest data (i.e. not from the cache)
			cacheWasUsed = false;
			user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
			this.preAuthenticationChecks.check(user);
			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
		}
		this.postAuthenticationChecks.check(user);
		if (!cacheWasUsed) {
			this.userCache.putUserInCache(user);
		}
		Object principalToReturn = user;
		if (this.forcePrincipalAsString) {
			principalToReturn = user.getUsername();
		}
		return createSuccessAuthentication(principalToReturn, authentication, user);
	}


}

[DaoAuthenticationProvider] --- 最终鉴权的类

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
// 忽略其它方法
@Override
	@SuppressWarnings("deprecation")
	protected void additionalAuthenticationChecks(UserDetails userDetails,
			UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
		if (authentication.getCredentials() == null) {
			this.logger.debug("Failed to authenticate since no credentials provided");
			throw new BadCredentialsException(this.messages
					.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
// 此处进行密码的校验
		String presentedPassword = authentication.getCredentials().toString();
		if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
			this.logger.debug("Failed to authenticate since password does not match stored value");
			throw new BadCredentialsException(this.messages
					.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
	}
}