前言

这篇文章是阅读 Arch Linux 团队部署、管理、整合身份与访问管理( Identity and Access Management)服务相关代码的笔记,会包含:

  • 如何部署和管理 Keycloak 服务:Ansible 相关
  • 如何管理 Keycloak 上的用户和权限:Terraform 相关
  • 如何将 Web 站点接入通过 Keycloak:OAuth2.0、OIDC、Python、Authlib 相关
  • 如何基于 Keycloak 中的信息,实现权限管理:装饰器和枚举类相关

IAM 服务

有什么用

先说说没有 IAM 服务会有什么问题。

一般团队内会有多种服务,比如:GitLab、Jira、Confluence、MatterMost、Grafana、Kibana、Argo 等,会产生的问题:

  • 每个团队成员入职/离职时,需在每个服务上添加/删除账号
  • 每个团队成员使用服务时,需在每个服务上输入一遍账号密码
  • 每个团队成员需要管理多个服务的密码,如果密码泄漏且相同时,每个服务需要手动修改一遍

IAM 服务的作用:

  • 统一的权限管理。授权服务器可以管理用户对各个应用的访问权限,例如,哪些用户可以访问哪些应用,用户在各个应用中的角色和权限是什么,另外用户账号的增删也只需要一遍。
  • 单点登录。用户只需要在授权服务器上进行一次登录,就可以访问所有连接到该授权服务器的应用。提高用户体验,减少了用户需要记住多个凭证的负担。
  • 降低业务复杂度。授权码流程可以将认证功能分离出业务代码,业务代码只需根据 OAuth2.0、OIDC 获取到的用户信息,更新和管理用户权限相关的用户表,而无需再每个业务内实现2FA、验证码等验证逻辑。
  • 微服务改造的基础。微服务旨在将业务拆分成独立小服务(独立的数据库)从而降低复杂度、提高容错率和扩展性。IAM 在微服务架构中为各个微服务提供了集中、统一的身份验证和权限管理,从而实现安全性,减少重复验证的开销,并提高系统的可维护性和扩展性。

Keycloak 是什么

Keycloak 是 RedHat 开源的身份认证和授权服务实现。通过部署 Keycloak,我们就可以拥有自己的 IAM 服务,Arch Linux 团队的 IAM 服务就是基于 Keycloak。

Keycloak 的部署

Arch Linux 团队的 Keycloak 通过 Ansible 进行部署和管理,相关的 Playbook 和 Role 在这里:

Playbook 地址

Role 地址

关于 Playbook 和 Role: Arch Linux 团队使用 Ansible 和 Terraform 的目的是为了实现运维代码化而不是自动化运维工具。

运维代码化是:将运维操作像业务代码一样放到 Git 内,基于传统的开发流程实现运维的管理,开发者以管理代码的方式管理服务器(运维即代码),包括了解服务器上过往所有改动的细节。

回到 Ansible。Ansible Role(是一个有固定目录结构的文件夹)是对应一类中间件或操作的抽象,比如:

  • common role: 用于服务器的初始化
  • firewalld role: 用于配置防火墙规则
  • sshd role 和 root_ssh role: 用于实现 sshd 配置管理和用户公钥证书管理
  • prometheus-exporters role: 用于安装 node-exporter 实现服务器基本的监控数据暴露
  • nginx role: 用于实现 nginx 的部署和配置管理
  • certbot role: 用于实现 letsencrypt 证书自动申请和刷新
  • postgres role: 用于实现 PostgreSQL 的部署和管理
  • keycloak role: 用于实现 Keycloak 的部署和管理
  • borg_client role: 用于实现备份系统的接入,实现服务器的每天自动备份
  • fail2ban role: 用于实现机器人暴力攻击防御,比如 SSH 暴力登录,如果一个用户 15 分钟内登录失败 5 次,就把该用户的公网 IP 加入到黑名单,关小黑屋一天
  • promtail role: 用于实现日志系统的接入,把服务器上 journald、pacman、nginx 等日志采集到 loki

Ansible Playbook(是一个文件)对应一个服务。如果 Role 是某一个操作的抽象,Playbook 就是按需选择要使用哪些 Role 然后像叠乐高一样把这些 Role 拼起来。

通过在 Playbook 内使用相同的 Role,可以实现运维代码的复用,提高运维操作的一致性,比如所有的服务器都有相同的 common role,这样初始化出来的服务器就是相同的了。

Keycloak 的部署操作如下(实际就是对应的 role 的功能):

  • 初始化服务器
  • 初始化 SSH 服务并添加允许访问服务器的开发者公钥
  • 配置防火墙
  • 配置主机监控
  • 部署 Nginx、PostgreSQL、Keycloak 并配置 HTTPS 证书
  • 配置备份、流控、日志收集

正因为 Arch Linux 团队实现了非常成熟的运维代码化,我才有机会了解他们实现 IAM 服务的细节(具体的细节不展开,见仓库中的代码)

Keycloak 的管理

Arch Linux IAM 服务的地址是: https://accounts.archlinux.org/

整个 Keycloak 中的信息通过 terraform 管理,比如:

  • 用户所需的组(类似于 Linux 的用户组)
  • 不同应用的 OIDC 配置(gitlab、grafana etc.)
  • 认证流程(用户密码登录、2FA认证、WebAuth 认证等)

地址如下:keycloak.tf

terraform 管理分组信息的例子

terraform 定义分组信息的代码,见:terraform 代码

terraform 管理 OIDC 配置的例子

client_secret 这个字段(917行)实际传入的是一个引用,而不是具体的密钥。这里是值得借鉴的地方,通过采用 hashicorp/external provider 将私密信息存储到状态库,解决 terraform 代码化运维中私密信息如何存储问题。

权限管理的实现

在 security.archlinux.org 这个站点的 terraform OIDC 配置中

通过 Terraform 代码:

resource "keycloak_openid_group_membership_protocol_mapper" "group_membership_mapper" {
  realm_id  = "archlinux"
  client_id = keycloak_openid_client.security_tracker_openid_client.id
  name      = "group-membership-mapper"

  claim_name = "groups"
}

定义了一个 claim,作用是:当用户通过 security.archlinux.org 向 Keycloak 发起授权码认证时,让 Keycloak 把用户归属的组的信息,以键名为 groups 的键值对方式提供给 security.archlinux.org 服务端。这样,服务端即可通过 Keycloak 的分组信息管理站点内用户的权限。

从 Keycloak 获取的 groups 字段的值是一个数组,比如 Arch Linux 安全团队的管理员的 groups 字段长这样:

['/Arch Linux Staff/Security Team/Members', '/Arch Linux Staff/Security Team/Admins']

表示这个成员首先是安全团队的 Members(成员),也是 Admins(管理员),这里跟分组信息的 Terraform 代码相对应。

下面是 security.archlinux.org 这个站点基于 authlib 实现授权码流程的 Python 代码:完整代码在这里

def sso_auth():
    # 为了方便阅读做了删减
    token = oauth.idp.authorize_access_token()
    userinfo = token.get('userinfo')
    idp_user_sub = userinfo.get('sub')
    idp_groups = userinfo.get('groups')
    user_role = get_user_role_from_idp_groups(idp_groups)
    user = db.get(User, idp_id=idp_user_sub)
    if user:
        user.role = user_role
    else:
        salt = random_string()
        user = db.create(User,
                         name=idp_username,
                         salt=salt,
                         password=hash_password(random_string(TRACKER_PASSWORD_LENGTH_MAX), salt),
                         role=user_role,
                         active=True,
                         idp_id=idp_user_sub)
        db.session.add(user)
    db.session.commit()
    user = user_assign_new_token(user)
    user.is_authenticated = True
    login_user(user)

上面的代码实现了从 Keycloak 获取用户信息实现登录的操作,通过获取 sub(即用户 ID) 查数据库,判断是否有这个用户(没有就创建),并通过groups 来判断用户的权限。

如何基于 groups 的信息实现授权管理呢?

get_user_role_from_idp_groups 的实现:

# idp_groups = userinfo.get('groups')
# user_role = get_user_role_from_idp_groups(idp_groups)

def get_user_role_from_idp_groups(idp_groups):
    group_names_for_roles = {
        SSO_ADMINISTRATOR_GROUP: UserRole.administrator,
        SSO_SECURITY_TEAM_GROUP: UserRole.security_team,
        SSO_REPORTER_GROUP: UserRole.reporter
    }

    eligible_roles = [group_names_for_roles[group] for group in idp_groups if group in group_names_for_roles]

    if eligible_roles:
        return sorted(eligible_roles, reverse=False)[0]
    return None

上面的代码中定义了一个 名为:group_names_for_roles 的 python dict,目的是:将从 Keycloak 获取到的分组信息的字符串,同业务代码中用于表示权限的枚举类实例做映射·。

SSO_ADMINISTRATOR_GROUPSSO_SECURITY_TEAM_GROUPSSO_REPORTER_GROUP 是项目配置文件配置项的值,跟下图红框内容相对应,见:配置文件

UserRole.administratorUserRole.security_teamUserRole.reporter 是枚举类 UserRole 的枚举类实例,见:代码

通过将 User 表的 role 字段设为 UserRole 枚举类类型(见下图),实现用户权限从 Keycloak groups 键值对到数据库字段值的映射。

eligible_roles = [group_names_for_roles[group] for group in idp_groups if group in group_names_for_roles]

这段代码是 Python 的列表推导,通过遍历值为:['/Arch Linux Staff/Security Team/Members', '/Arch Linux Staff/Security Team/Admins']idp_groups 数组(也就是从 groups 的值),如果该数组成员的值是 group_names_for_roles 这个字典的键,则将该键对应的枚举类实例添加到 eligible_roles 数组内。

下图中没有直接使用 SQLAlchemy 的 Enum 类型,是因为:UserRole(上图)的枚举类实例的值不是基础数据类型而是元组 'Administrator', 1 ,故做了类型增强,比如:通过继承python Enum 类型,实现基于枚举值(元组)中第二个成员(整数值类型)为排序条件的、枚举类的子类 OrderedDatabaseEnum

class OrderedDatabaseEnum(DatabaseEnum):
    def __init__(self, label, order):
        super().__init__(db_repr=self.name, label=label)
        self.order = order

    def __lt__(self, other):
        return self.order < other.order

这样,当 groups['/Arch Linux Staff/Security Team/Members', '/Arch Linux Staff/Security Team/Admins'] 的团队成员,通过 get_user_role_from_idp_groups 函数映射出来的枚举类实例为:[UserRole.security_team, UserRole.administrator],因为 UserRole 的父类 OrderedDatabaseEnum 为元组类型的枚举类实例值实现了 __lt__ 方法,所以枚举类实例所在的数组 [UserRole.security_team, UserRole.administrator] 就可以被排序。

sorted(eligible_roles, reverse=False)[0]

实际上就是对数组 eligible_roles 执行排序,让 [UserRole.security_team, UserRole.administrator] 变成 [UserRole.administrator, UserRole.security_team] 并只保留第一个数组成员作为代表用户权限的角色。一个团队成员,如果既有普通权限又有高级权限,那么我们就认为他是管理员角色。

在后面基于枚举类实例的权限判断逻辑中:

可以看到,当我们从 groups 获得代表权限的枚举类实例后,UserRole.administrator 出现在了 is_reporter is_security_team is_administrator 三个判断逻辑内,即管理员会有所有普通的权限。

业务代码中是如何校验用户权限的呢?这块是通过装饰器 permission_required 实现的:

上面的代码中:permission_required 是一个返回装饰器的装饰器。通过传入一个具体的枚举类属性方法(即下图中的 is_guest/is_reporter/is_security_team/is_administrator)返回一个可以装饰视图函数的装饰器:

故整个业务的认证流程是:

  • 运维代码中:
    • 通过 Ansible 部署 Keycloak
    • 通过 Terraform 控制 Keycloak 管理用户,存储用户分组信息、并提供 OIDC 接入的能力
    • 基于 OAuth2.0 和 OIDC 的授权码流程,通过 Terraform 配置 Keycloak,在 ID token 中添加名为 groups 的用户分组信息键值对,让业务代码获取到分组信息
  • 业务代码中:
    • groups 键值对中的字符串信息转换成表示权限的枚举类实例
    • 通过用户 ID 判断用户表内是否已经有该用户,没有就创建用户
    • 根据 groups 信息更新用户的权限。即为当前用户的 role 属性指定一个表示当前用户权限的枚举类实例
    • 最后通过 flask_login 的更新当前用户的 session id、并标记用户为经过认证的状态

总结

这篇文章是关于 Arch Linux 团队从基于 Ansible 自建 IAM 服务:Keycloak;到基于 terraform 管理 IAM 服务中的用户分组信息;再到基于 Flask、SQLAlchemy 实现认证和授权过程的记录。

如果你正在实现业务的 OAuth2.0、OIDC 整合,并希望项目有良好的 DevOps 实践,那这篇文章涉及到的代码:

  • 代码化运维仓库:https://gitlab.archlinux.org/archlinux/infrastructure
  • 业务代码仓库:https://github.com/archlinux/arch-security-tracker

是个不错的参考来源。另外,Arch Linux 的业务代码有相对完整的单元测试,这也是非常好的实践参考。