Growing 账号认证实践

572 阅读5分钟

背景

GrowingIO 作为专业的数据运营解决方案提供商,我们的客户来自不同的行业,但他们都有相同的安全需求。在众多的客户中,许多客户都有自己的账号认证系统。因此我们需要能通过简单的配置接入客户的账号认证系统。目前 GrowingIO 一共支持了 CAS, OAuth2, LDAP 三种不同的接入协议。本文将详细介绍我们是如何支持这三个接入方式的。

不同接入协议的认证流程

OAuth2 一般来说,使用 OAuth2 来实现认证都是使用的授权码模式,我们这里也不例外,下面是 OAuth2 授权码模式的标准流程。

(A)用户访问客户端,后者将前者导向认证服务器。 (B)用户选择是否给予客户端授权。 (C)假设用户给予授权,认证服务器将用户导向客户端事先指定的"重定向URI"(redirection URI),同时附上一个授权码。 (D)客户端收到授权码,附上早先的"重定向 URI ",向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。 (E)认证服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌( refresh token)。

LDAP LDAP 全称 Lightweight Directory Access Protocol,中文名称轻量目录访问协议。认证登录是其主要应用场景之一,下面是 LDAP 认证的流程。

CAS CAS 是一个开源的 Java 服务器组件,给企业提供 Web 单点登录服务,CAS 服务器是构建在 Spring Framework 上的 Java 应用程序,其主要职责是通过颁发和验证票证来验证用户并授予对启用 CAS 的服务(通常称为 CAS 客户端)的访问权限。当服务器在成功登录后向用户发出票据授予票据 (TGT) 时,将创建 SSO 会话。服务票证 (ST) 根据用户的请求通过浏览器重定向使用 TGT 作为令牌颁发给服务。ST 随后在 CAS 服务器上通过反向通道通信进行验证。

整体架构

总体思路

整体上就是把这三种认证方式都集成到了 OAuth2 授权码模式的流程中,认证中心 IAM 系统在对接不同的认证协议时扮演了不同的角色。

  • 对于账号密码认证, IAM 扮演的是 OAuth Server,用户的信息保存在数据库 users 表中,网关Gateway 扮演的是 OAuth Client,走的是标准的授权码流程。

  • 对于 LDAP 认证,IAM 扮演的是 LDAP Client,在认证过程中把用户的用户名和密码拿到 LDAP Server 进行查询,如果查询到合法用户则代表认证成功,之后再走后续的授权码流程。

  • 对于 CAS 认证,IAM扮演的是 CAS Client,在获取授权码的接口处理逻辑中,IAM 会检查用户是否认证过,如果没有认证过,会把用户重定向到 CAS Server 去进行认证。在 CAS 的认证回调接口中,根据 ticket 换取用户信息,之后设置用户的认证状态并且生成自己的授权码,后面就是标准的授权码流程。

  • 对于 OAuth2 认证,IAM 扮演的是 OAuth Client,和 CAS 认证的思路相同,发现用户没有认证时把用户重定向到 OAuth Server 去进行认证或者授权。在 Oauth Server 的授权码回调接口中,根据返回的授权码拿到 token,进而拿到用户信息,之后设置用户的认证状态并且生成自己的授权码,后面就是标准的授权码流程。

登录流程

账号密码 & LDAP

CAS & Oauth2

关键步骤伪代码

Gateway Gateway 除了作为反向代理之外,还承担了一部分 OAuth Client 的功能,用来帮助前端设置 OAuth Client 的参数和获取 token 以及刷新 token。

# Gateway 作为 OAuth client,前端登陆时需要先访问 /authorize,
# Gateway 负责拼接上 OAuth client的参数
location /authorize {
    local authorize_uri = '/oauth/authorize?client_id=gateway&response_type=code&redirect_uri=xxx/oauth/callback'
    redirect to authorize_uri
}

# 标准 Oauth2 授权码接口,第三方系统携带自己的参数访问
location /oauth/authorize {
    proxy IAM
}

# 接入 CAS 或者 OAuth 认证时,接受认证码( tocket/code )的接口
location /sso/callback {
    proxy IAM
}

# Gateway 处理授权码的回调接口,调用 IAM 服务获取 token
location /oauth/callback {
    local redirect_uri = 'xxx/oauth/callback'
    local auth_info = {
        grant_type = 'authorization_code',
        code = args.code,
        client_id = oauth2.client_id,
        client_secret = oauth2.client_secret,
        redirect_uri = redirect_uri
    }
    # 根据 Gateway 的 client_id 等信息和 code 获取token
    local token = getTokrnByCode(auth_info)
    # 拿到token可以返回给前端
}

# 获取 OAuth Token 的接口,第三方系统可以拿到授权码之后调用此接口获取 token
location /oauth/token {
    proxy IAM
}

IAM IAM服务使用 spring-security-oauth2 来构建 OAuth Server,依赖了 spring-security-ldap 和 spring-security-cas 来集成 LDAP 和 CAS 的标准流程。

框架依赖

implementation("org.springframework.security.oauth:spring-security-oauth2:2.3.6.RELEASE")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.security:spring-security-ldap")
implementation("org.springframework.security:spring-security-cas")

SSO接入配置

# 接入客户的哪种认证方式
grant:
  type: LDAP or ORIGIN or CAS or OAUTH
# 接入 CAS 认证时,CAS Server 的地址
cas:
  serverUrl: https://xxx:8443/cas
# 接入 LDAP 认证时,链接 LDAP SERVER 的配置
ldap:
  type: ad or openLdap
  domain: xxx.com
  url: ldap://xxx:389
  rootDn: dc=xxx,dc=com

认证方式配置

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
    @Value("${grant.type}")
    private GrantType grantType;
    
    @Bean
    public AuthenticationProvider defaultDaoAuthenticationProvider(){
        DaoAuthenticationProvider defaultDaoAuthenticationProvider = new DaoAuthenticationProvider();
        defaultDaoAuthenticationProvider.setUserDetailsService(userDetailsService);
        defaultDaoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return defaultDaoAuthenticationProvider;
    }
    
    //根据配置,创建不同的认证管理器
    public AuthenticationProvider grantTypeAuthenticationProvider(){
        AuthenticationProvider grantTypeAuthenticationProvider = null;
        switch (configs.getGrantType()){
            case LDAP:
                LdapAuthenticationProvider ldapAuthenticationProvider = new LdapAuthenticationProvider(configs);
                ldapAuthenticationProvider.setUserDetailsContextMapper(userDetailsService);
                grantTypeAuthenticationProvider = ldapAuthenticationProvider;
                break;
            case OAUTH:
                final DefaultAuthorizationCodeTokenResponseClient tokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
                final DefaultOAuth2UserService oAuth2UserService = new DefaultOAuth2UserService();
                grantTypeAuthenticationProvider = new OAuth2LoginAuthenticationProvider(tokenResponseClient, oAuth2UserService);
                break;
            default: break;
        }
        return grantTypeAuthenticationProvider;
    }
    
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() {
        List<AuthenticationProvider> providers = new ArrayList<>();
        providers.add(defaultDaoAuthenticationProvider());
        AuthenticationProvider grantTypeAuthenticationProvider = grantTypeAuthenticationProvider();
        if (Objects.nonNull(grantTypeAuthenticationProvider)) {
            providers.add(grantTypeAuthenticationProvider);
        }
        return new ProviderManager(providers);
    }
    
    //CAS 认证时,用来验证 ticket
    @Bean
    @ConditionalOnProperty(prefix = "grant",value = "type",havingValue = "CAS")
    public TicketValidator validator(){
        return new Cas30ServiceTicketValidator(casServer);
    }
    
}

回调接口逻辑

@GetMapping(value = "/sso/callback")
public void casCallBack(@RequestParam Map<String, String> parameters, HttpServletResponse response) throws IOException {
    String username;
    switch (configs.getGrantType()){
        case CAS:
            String ticket = parameters.get("ticket");
            // casServiceUrl 是前面 cas/login 时携带的 service
            //getUserNameByTicket
            username = xxx;         
            break;
        case OAUTH:
            String code = parameters.get("code");
            //getTokenByCode
            //getUserNameByToken
            username = xxx;
            break;
    }
    //SSO 认证成功,调用 IAM 的认证逻辑
    UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, "");
    Authentication authentication = authenticationManager.authenticate(authRequest);
    SecurityContextHolder.getContext().setAuthentication(authentication);
    //生成 OAuth 请求,带 code 重定向,让浏览器携带 code 访问 openresty 的 /oauth/callback 接口
    //走 IAM 的授权码流程
    AuthorizationRequest authorizationRequest = oAuth2RequestFactory.createAuthorizationRequest(parameters);
    OAuth2Request storedOAuth2Request = oAuth2RequestFactory.createOAuth2Request(authorizationRequest);
    OAuth2Authentication combinedAuth = new OAuth2Authentication(storedOAuth2Request, authentication);
    String code = authorizationCodeServices.createAuthorizationCode(combinedAuth);
    String redirectUrl = String.format("xxx/oauth/callback?code=%s",code);
    redirectUrl = response.encodeRedirectURL(redirectUrl);
    response.sendRedirect(redirectUrl);
}

参考:

[1] datatracker.ietf.org/doc/html/rf… [2] www.ruanyifeng.com/blog/2014/0… [3] www.apereo.org/projects/ca…