前提:本文的重点是从实践的角度浅析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】去实现的。
类关系如图所示:
可能这么说还是有点懵,我们通过下面跟代码
// 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"));
}
}
}
其中部分源码借鉴自开源项目 EL_ADMIN