Security对接 sso服务总结——代码篇

三阶段

  1. security web简单自定义认证:https://gitee.com/security-demo/web-login
  2. 简单自定义登录 + 自定义授权:https://gitee.com/security-demo/web-login-authorization
  3. 整合基于oauth2协议的sso服务:https://gitee.com/security-demo/oauth2-client-web-login

详解

第一二阶段

第一二阶段主要加深了解Security,添加自定义认证,登录校验,权限校验。

自定义登录过滤器

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// LoginFilter 中间省略了代码,具体看项目demo
// 自定义用户服务获取用户详情
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 确认密码是否正确
if (userDetails != null && PasswordEncoderFactories.createDelegatingPasswordEncoder().matches(password, userDetails.getPassword())) {
// 构建Authentction对象
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, authToken,
userDetails.getAuthorities());
// 将Authentction信息放入到security上下文对象中
SecurityContextHolder.getContext().setAuthentication(authentication);
// 将Authentction信息放入redis实现分布式共享登录状态
redisTemplate.opsForValue().set(TOKEN_REDIS_KEY + authToken, authentication, TOKEN_TIMEOUT);
}
}

自定义认证校验过滤器,当前后端分离的时候,我们会以token传输为认证标准,而不再是session。(尽管security很强大,他会自动以session方式做了绑定)

// AuthenticationFilter 中间省略了代码,具体看项目demo
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
if (antPathRequestMatcher.matches(request)) {
if (log.isDebugEnabled()) {
log.debug("自定义登录认证...");
}
String bearerToken = TokenUtil.getToken(request, response);
// 登录成功校验逻辑
if (StringUtils.hasText(bearerToken)) {
String authToken = bearerToken.substring(6).trim();
if (redisTemplate.hasKey(TOKEN_REDIS_KEY + authToken)) {
redisTemplate.expire(TOKEN_REDIS_KEY + authToken, TOKEN_TIMEOUT);
Object o = redisTemplate.opsForValue().get(TOKEN_REDIS_KEY + authToken);
// 通过自定义token的第一次访问,security上下文中没有Authentication
// 后面的访问都会通过SecurityContextPersistenceFilter从session获取到
if (o instanceof SecurityContext && SecurityContextHolder.getContext().getAuthentication() == null) {
SecurityContextHolder.setContext((SecurityContext) o);
}
if (o instanceof Authentication && SecurityContextHolder.getContext().getAuthentication() == null) {
SecurityContextHolder.getContext().setAuthentication((Authentication) o);
}
} else {
SecurityContextHolder.clearContext();
throw new BadCredentialsException("自定义登录标识无效");
}
}
}
chain.doFilter(request, response);
}

第三阶段,对接sso服务

自定义Oauth2登录授权成功处理器

// CustomOauth2AuthenticationSuccessHandler 中间省略了代码,具体看项目demo
// 将oauth2认证成功得到的Authentication添加上本系统配置的权限进行入库,存redis
private void customAuthentication(Authentication authentication) {
// 保存Oauth2登录获得的用户
Object principal = authentication.getPrincipal();
if (principal instanceof DefaultOidcUser) {
String name = ((DefaultOidcUser) principal).getName();
// 重新构建权限集合
Collection<? extends GrantedAuthority> authorities = ((DefaultOidcUser) principal).getAuthorities();
Set<GrantedAuthority> au = new HashSet<>();
for (GrantedAuthority authority : authorities) {
au.add(authority);
}
// 构建本地用户准备入本地用户库
User.UserBuilder builder = User.withDefaultPasswordEncoder()
.username(name)
.authorities(authorities)
.password(DEFAULT_PASSWORD);
if (userDetailsService.userExists(name)) {
UserDetails userDetails = userDetailsService.loadUserByUsername(name);
for (GrantedAuthority authority : userDetails.getAuthorities()) {
au.add(authority);
}
userDetailsService.updateUser(builder.authorities(Collections.unmodifiableSet(au)).build());
} else {
UserDetails user = builder.authorities(Collections.unmodifiableSet(au)).build();
userDetailsService.createUser(user);
}
// 构建本地token
String authToken =
new String(Base64.getEncoder().encode((name + ":" + DEFAULT_PASSWORD).getBytes(StandardCharsets.UTF_8)));
// 重构oauth2的authentication,添加本系统该用户已存在的权限。
// 并将其信息放入到security上下文对象中
if (authentication instanceof OAuth2AuthenticationToken) {
authentication =
new OAuth2AuthenticationToken(((OAuth2AuthenticationToken) authentication).getPrincipal(),
Collections.unmodifiableSet(au),
((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId());
SecurityContextHolder.getContext().setAuthentication(authentication);
redisTemplate.opsForValue().set(TOKEN_REDIS_KEY + authToken, authentication,
TOKEN_TIMEOUT);
if (log.isDebugEnabled()) {
log.debug("自定义oauth2登录成功处理器 : " + authentication.getName());
}
}
}
}

重要调试点

DefaultRedirectStrategy
org.springframework.security.web.DefaultRedirectStrategy#sendRedirect
所有的重定向都是以这个执行,这里打个断点监控很有必要

授权

  1. FilterChainProxy
    org.springframework.security.web.FilterChainProxy.VirtualFilterChain#doFilter
    此处可以看到security整个过滤器执行链路。
  2. SecurityContextPersistenceFilter
    org.springframework.security.web.context.SecurityContextPersistenceFilter#doFilter(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, javax.servlet.FilterChain)
    此处进去可以看到security会尝试从当前session获取SecurityContext
  3. OAuth2AuthorizationRequestRedirectFilter
    org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter#doFilterInternal
    此处用来解析当前请求为oauth2认证请求,进去可以看到解析原理是拿当前过滤器的请求匹配器匹配请求,对上了再确认register-id。都确定了,就重定向到指定的oauth2登录地址,由于我们配置了两个授权码登录client,所以调试的时候会有两个过滤器
  4. OAuth2LoginAuthenticationFilter
    org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter
    他继承了AbstractAuthenticationProcessingFilter,自己只做处理code获得Authentication。
    oauth2认证过滤器,该过滤器接oauth2认证的对调请求。此处我在AbstractAuthenticationProcessingFilter打了三个断点,OAuth2LoginAuthenticationFilter打了一个
    1)第一个断点,进行认证获得Authentication,认证成功的register会存至指定的认证成功客户端库,我们改成了以数据库存,实现持久化认证。避免系统重启oauth2认证状态丢失
    2)第二个断点,session保存认证状态,使得当前session拥有oauth2登录状态(效果:我们直接通过Cookie传sessionId的方法发起的请求是合法有效的)
    3)第三个断点,认证成功处理,进去第一行就是向security上下文添加Authentication。然后发送认证成功消息,调用默认的认证成功处理器。
    我们需要他oauth2登录能和自定义登录绑定到一起,所以我们重写一个认证成功处理器继承默认的认证成功处理器SavedRequestAwareAuthenticationSuccessHandler,然后再替换掉他就行。

  1. OAuth2AuthorizationCodeGrantFilter
    org.springframework.security.oauth2.client.web.OAuth2AuthorizationCodeGrantFilter#doFilterInternal
    正常情况下不会进来,因为携带code的请求会由OAuth2LoginAuthenticationFilter优先处理,处理完就已经获得Authentication
    而session在AbstractAuthenticationProcessingFilter的第二个断点处也会进行认证成功处理
    此处我打了两个断点
    第一个断点做是否处理判断,里面有三种情形会返回匹配失败,一个失败则不继续
    1)当前请求参数携带aouth2认证回调参数(code与state或error与state)
    2)当前请求不是oauth2认证成功请求(session在认证成功库里没有)
    3)重定向的scheme与当前请求的scheme相同
    第二个断点真正处理请求进行授权,大致步骤为
    1)确定当前oauth2对接使用的register,进行一系列乱七八糟的处理,最终回调到用户初始访问client服务的访问地址

认证

  1. FilterSecurityInterceptor
    org.springframework.security.web.access.intercept.FilterSecurityInterceptor#doFilter
    先说结论,该过滤器是用来做权限校验的。具体流程看下图
    看下图:构建了一个FilterInvocation对象,然后交由本地invoke方法执行。构建对象是只是进行了简单的非空校验与赋值。重点在于调用了父类方法AbstractSecurityInterceptor#beforeInvocation获得拦截器状态token。我们一个一个看。

    下代码即invoke方法实际执行
public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
if (isApplied(filterInvocation) && this.observeOncePerRequest) {
// filter already applied to this request and user wants us to observe
// once-per-request handling, so don't re-do security checking
// 过滤器已应用于此请求,用户希望我们观察
// 每个请求处理一次,因此不要重新进行安全检查
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
return;
}
// first time this request being called, so perform security checking
// 第一次调用此请求时,请执行安全检查
if (filterInvocation.getRequest() != null && this.observeOncePerRequest) {
filterInvocation.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
// 重点,用父类生成一个拦截器状态token,这个token就表示了该请求的权限状态
InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
try {
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}

此处执行流程为:
1)调用AbstractSecurityInterceptor#beforeInvocation获取 InterceptorStatusToken
AbstractSecurityInterceptor#beforeInvocation里就三个重点方法
第一个:authenticateIfRequired(),当当前的authentication已经认证过了,并且没有设置成每次访问都要刷新authentication的话直接返回原authentication
否则调用绑定的AuthenticationManager#authenticate进行登录认证重新获得authenticate并刷新。

第二个:attemptAuthorization(object, attributes, authenticated),看名字是添加授权方法,名字不太好理解,看代码更方便。调用访问策略管理器进行访问决定。
他有三个实现类,就是有三种策略管理器,内部实现都是采用内部所有投票器的投票(1,同意。0,弃权;-1:反对)结果来判定是否可以访问。相同点是如果都是弃权的话,依然会抛出AccessDeniedException拒绝访问。
AffirmativeBased:有一个投票同意就允许访问
UnanimousBased:有一个投票反对就拒绝访问
ConsensusBased:统计所有投票结果,同意与反对的票数谁多谁说了算。如果一样多,并且允许的票数不为零,默认允许访问,如果手动设置allowIfEqualGrantedDeniedDecisions属性为false则不允许访问

第三个:this.runAsManager.buildRunAs(authenticated, object, attributes)
这个方法的含义是为当前Authentication添加权限,在这里其实也可以作为自定义的权限授予。只是他是维护请求匹配器与权限或者方法与权限的关系。请求匹配上了,这里就给你加上权限。
对于分布式动态权限不好管控,所以我们就没用这里实现自定义权限

2)过滤器链继续执行过滤器。
此时FilterChainProxy代理的过滤器已经执行完毕了,会转到ApplicationFilterChain继续执行他的内部过滤器,此处已超出security范围,感兴趣的自看...
3)调用AbstractSecurityInterceptor#finallyInvocation
不管上面的ApplicationFilterChain执行的怎么样,我们依然会继续来到此处进行SecurityContext刷新
4)调用AbstractSecurityInterceptor#afterInvocation
该方法最后一遍确认token,校验权限。
如果应用里面使用后置权限校验注解@PostAuthorize,那么afterInvocationManagerproviders里面就会有一个PostInvocationAdviceProvider进行处理

结尾

spring security + oauth2 sso server + 自定义登录,权限 基于spring web的整合大致就算结束了。
整体来说,内置用tomcat的话就是在ApplicationFilterChainfilters里面加了一个security的FilterChainProxy。security的所有认证、授权、对接oauth2认证服务等一系列的操作都在FilterChainProxy里面。我们只需要慢慢理解这个FilterChainProxy调用了哪些过滤器,这些过滤器的初始话在哪里?慢慢看就行了。

security初始化反推。我们按照官方日志配置后,启动能确定FilterChainProxy
然后在Intellij Idea里面找FilterChainProxy的引用,找到了WebSecurityConfiguration,
他上面标注了@Configuration(proxyBeanMethods = false)注解。再找他的引用。找到了@EnableWebSecurity注解。
看到这里一眼就能看出来是明显的级联配置。只要spring boot启动时扫到了这个注解所标注的类。那么security环境就启动了

logging:
# file:
# name: oauth2-client.log
level:
# 根日志级别
root: DEBUG
# 基础web相关日志级别
org.springframework.web: TRACE
# security相关日志级别
org.springframework.security: TRACE
# oauth2相关日志界别
org.springframework.security.oauth2: TRACE
# spring-boot自动配置日志级别
org.springframework.boot.autoconfigure: INFO
posted @   临渊不羡渔  阅读(701)  评论(0编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
点击右上角即可分享
微信分享提示