Skip to main content

使用逻辑

在前面我们已经了解到:Keycloak = 一个“身份域 + 身份数据 + 认证协议引擎”的服务

不存业务数据,只负责用户的身份和访问权限认证。

(一) 认证流程

回忆在 intro 里讲解的“认证流程”,具体步骤如下:

场景 1:用户注册

  1. 用户访问 应用软件
  2. 被重定向到 Keycloak 注册
  3. Keycloak 创建 User,并分配一个 唯一的 用户ID sub ,然后返回含 subJWT
  4. 应用软件 后端收到 JWT,用 sub 查询 应用数据库
  5. 发现没有 → 创建 Focus 用户

场景 2:用户登录

  1. 用户访问 应用软件
  2. 被重定向到 Keycloak
  3. **Keycloak 发现已经登录 → 不用注册 **→ 得到 access_token
  4. 用得到的 access_token 调用 Ordinis Learning 的后端API ,Learning 后端收到 JWT(就是 access_token
  5. 应用软件 后端验证 access_token(签名/issuer/audience)
  6. 应用软件 后端从 access_token 里取 sub(Keycloak userId),
  7. 应用软件 后端用 sub 查 业务数据库 Learning DB,获取用户的业务信息,查/创建用户(首次登录自动建档)

由上面的流程可知:用户的信息其实在 Keycloak 数据库应用软件数据库 各存了一份,使用的 UID 分别是 subuser_id 。应用软件后端 在收到传过来的 JWT 里面的 sub 之后,其实不能马上用 sub 去 查数据库,而是先要通过一张映射表(通常在 user 里)把 sub 映射成自己的 user_id(UUID),然后再用 自己的 user_id(UUID) 去查业务数据库。

Keycloak 发的 JWT(access token):

  • sub:用户唯一 ID(你用来做业务侧 user 绑定)
  • email / preferred_username:可选用于展示/同步
  • realm_access.roles / resource_access:角色(做粗粒度授权)
  • exp:过期时间(后端校验)

注意:Keycloak 的 access_token 是直接返回给前端,前端再去调用 应用软件的后端API 。

Keycloak 只负责“发 token”,从不该调用你的后端 API,更不会转发业务响应。所有业务 API 调用,都是:前端 → 你的后端。

Browser
↓ 用户访问你的应用(前端)
https://focus.ordinis.app
前端发现 未登录 → 发起 OIDC 登录。
↓ 302 redirect,跳转到 Keycloak
https://auth.ordinis.dev/realms/ordinis/protocol/openid-connect/auth
做完认证
↓ 302 redirect 跳回前端
https://focus.ordinis.app/callback?code=AUTH_CODE
前端(或 BFF)向 Keycloak 的 token endpoint 请求:POST /protocol/openid-connect/token
得到:
- `access_token`(JWT)
- `id_token`
- `refresh_token`
↓ 前端调用你的后端 API
GET https://api.focus.ordinis.app/todos
Authorization: Bearer <access_token>


注意:OIDC 登录一定要“跳转到 Keycloak 页面”吗?

是的,用户会被重定向到 Keycloak 的登录页面。不推荐在前端自己做账号密码登录,再用 API 和 Keycloak 通信

这是因为OAuth / OIDC 的根本设计哲学:你的应用永远不应该直接接触用户密码

如果你“自己做登录页 + API 调 Keycloak”,这叫 ROPC(Resource Owner Password Credentials) 模式:

前端收集用户名 + 密码

POST 给 Keycloak token endpoint

⚠️ **严重问题:**前端拿到明文密码(XSS 风险),也需要自己制作 MFA,社交登录等功能。

而且也不用担心 登录页面“风格不统一”,因为Keycloak 页面 的HTML / CSS / Logo 是可以自定义的


(二)Keycloak 的核心对象

我们接下来要以 Ordinis 为品牌做软件矩阵,旗下会包括多款软件比如:

  • Oridinis Focus
  • Oridinis Learning
  • Oridinis SMS

虽然属于同一个品牌,但是每个软件都应该要有自己的独立的一套用户系统,互相之间隔离。


1. Realm(域)

一个 Realm = 一套完全独立的用户系统

Keycloak 中默认含有一个 Realm: master,但是这个 Realm 是给Keycloak系统管理用的,不要给真实用户用。

在 Keycloak 中,所有 Ordinis 旗下的软件都应该属于 同一个 Realm:Ordinis,这个 Realm 是新创建的。


2. Client(应用 / 服务)

Client = 一个要“使用 Keycloak 登录能力的应用”

比如我们接下来要开发的软件 Oridinis Focus,就是一个 Client

Client 决定:

  • 用什么协议(OIDC / OAuth2)
  • 怎么回调(redirect_uri)
  • token 里带什么信息
  • 是否是 public / confidential

3. User(用户)

注册成为 Ordinis Focus 用户 = 在 Keycloak 创建 User

例如:所有 Oridinis Focus 的用户都 同样注册为 Keycloak 的用户

但是这个用户信息不是通用于所有 Realm 的,每个软件自己的用户数据 = 各自独立的数据库

Keycloak 不是 MySQL,不是一个数据库, Keycloak 的每个用户和 具体软件的用户表是直接关联的,不需要像 MySQL 一样为每个 Realm 创建专有 “系统用户” 来读写数据,

Keycloak User 包含:

类型举例
基础属性username / email / name
凭据password / OTP / WebAuthn
状态enabled / emailVerified
元数据attributes(KV)

注意:用户表的最小隔离单位是 Realm 而不是 Client,这意味这同一个软件矩阵下,用户在 Oridinis Focus 中注册了 Keycloak 身份,这个身份会保存到 Oridinis 这个 Realm 中,因此 Oridinis Learning 也可以访问到 这个用户。但是这不意味着 Oridinis Focus 的用户会自动成为了 Oridinis Learning 的用户。因为在应用软件后端一侧还有业务数据库,即如 Oridinis Learning DBOridinis Focus DB

例如: Oridinis Focus 的用户在没有注册 Oridinis Learning 的情况下去尝试登录 Oridinis Learning ,虽然 Keycloak 能找到该用户并返回一个 sub,但是 Oridinis Learning DB 的映射表中不存在该 subuser_id 的对应关系。所以会登录失败,或者直接创建新用户(这就是站内 SSO)


接着上面的问题思考:既然具体的用户登记是在 应用软件的业务数据库记录 的,这个 Client 还有什么用?直接使用一个 Realm 不就好了吗?为什么还要在 Realm 下创建那么多 Client?

这是因为,定制 token 的最小隔离单位是 Client 而不是 Realm。

例如 Access Token 里一定有一个字段:

"aud": "client_id"

使用这个 字段,可以清晰地显示 “这个用户属于哪个 Client,也就是哪个具体应用软件"

同时,每个 Client 可以有不同的登录策略,比如是否允许注册,是否强制邮箱验证,是否支持社交登录,是否要求 MFA,登录后跳转到哪里,这些都是 在 Client 层面定制的

因此:你现在的直觉是:

既然用户登记在业务数据库,Client 好像没啥用

更准确的说法是:

Client 不负责“用户是否存在”, Client 负责“这个身份以什么方式、访问哪个应用、拿到什么 token”。


4. Role(角色 = 权限语义)

  • Realm Role(领域级角色):

    即这个身份在这个 Realm 中是什么层级 / 身份,相当于维护这个 Realm 的 “员工” 而不是被记录进去的 “用户”。一般分为useradminpremiumstaff

  • Client Role(应用级角色)

    某一个 Client 中记录的用户,也就是业务用户,只对某个 Client 有意义

    非常适合做权限控制:Token 中通常嵌在:

resource_access: {
  ordinis-api: {
    roles: ["todo.read"]
  }
}

思考:什么叫用户的权限?用户在具体应用软件的权限,比如是否是VIP,不是记录在 应用软件的业务数据库中的吗?为什么 Client 中要记录什么 read write权限?


Client Role里的“权限”(read / write),不是业务权限:是否 VIP、是否付费、是否解锁某个具体功能

Client Role 解决的是:“这个 token,能不能访问某一类 API”。它是一个安全边界声明,不是业务状态。

例如 Realm 层面 验证了这个用户确实已经注册过了(sub 存在), 但是 不知道这个身份有没有资格调用这个服务 / 接口族,例如能不能访问 /api/focus/*,能不能访问 /api/learning/*,是不是管理员接口

因此 Client 层面制定 的 Role 权限,就规定了这些。如果你不用 Client Role,所有 API 都接受任何登录用户,这是不安全也不方便的。



5. Group(用户集合)

Group 是 Role 的容器

  • Group 可以绑定多个 Role
  • User 加入 Group = 自动继承 Role

非常适合:

  • 免费用户 / 付费用户
  • 学生 / 教师
  • 内测 / 正式

6. Protocol Mapper

决定 token 里“有什么”

例如:

  • token 里有没有 email?
  • role 是不是写进 JWT?
  • custom attribute 要不要暴露?

你后端解析 JWT,全靠这里。


(三)创建 Client

接下来开始做实操流程:从创建 Realm 开始,后端用 Flask 搭建一个最小服务,目的是跑通 ”注册“ ”登录“ 这两个流程

现在 Keycloak 已经能通过 https://auth.ordinis.dev 正常访问,并且 Keycloak 在容器里监听 127.0.0.1:8081 -> 8080,Nginx/Cloudflare 做外部 HTTPS 入口。

我们先明确理想效果为:

  • 访问 https://focus.ordinis.dev/
  • 点“Login” → 跳转到 auth.ordinis.dev 的 Keycloak 登录页
  • 登录页里点 “Register” 完成注册(Keycloak 自带)
  • 登录成功后回跳到 https://focus.ordinis.dev/callback
  • Flask 拿到 token,并显示当前登录用户信息(sub / preferred_username / email 等)

1. 创建 Realm

登录 Keycloak Admin Console(Master realm),左上角下拉 → Create realm → Realm name:Ordinis

Resource file = “用一个 JSON 文件一次性导入一个 Realm 的完整配置”,第一次创建空着就好

image-20260112000904999

然后进入新 Realm:ordinis

image-20260112001155029

2. 开启“用户自助注册”

Realm settingsLogin → 开启 User registration

  • (可选)开启: Email as username/Verify email / Forgot password 等(后面有了邮件服务器再加)

这样注册流程由 Keycloak 提供:用户在登录页会看到 “Register”。这是最小跑通注册的方式。

image-20260112001445302

3. 创建 Client

进入 ordinis Realm → Clients,默认的内容如下:

image-20260112001606656

这些 Clients 不是给你业务用的,是 Keycloak 自己的基础设施 Clients。因为 Keycloak 本身就是一个 OIDC / OAuth2 系统, 而 Keycloak 的控制台、账户中心、API、联邦功能,全都是“Client”形式在使用 OIDC 的。相当于是“用 OIDC 管 OIDC”**

  • security-admin-console:👉 一个 Web Client,你现在看到的 Keycloak 管理后台本身

  • realm-management:👉 定义“谁可以管理这个 Realm”

  • 它提供的 Client Roles 包括:create-realmmanage-usersmanage-clientsview-realmmanage-events等等。当你给用户分配:

    Client: realm-management
    Roles: manage-realm / create-realm / manage-users

    你本质上是在说:“这个用户可以管理整个 Realm”

  • account:👉 用户自助服务中心(改密码、看会话、看登录设备),在 /realms/Ordinis/account/

  • account-console:👉 新版 Account UI 的前端 Client,本质上是 account 的 UI 版本

  • admin-cli:👉 命令行 / API 管理 Realm,比如:kcadm.sh get users -r ordinis 就是通过这个 Client 发 Token 的。

  • broker:👉 Identity Broker(第三方登录),比如:Google,GitHub,学校 SSO,企业 IdP



点击 Create client

  • Client type:OpenID Connect(OIDC)

    • OAuth2 解决“授权访问资源”(access token)
    • OIDC 在 OAuth2 上再加一层身份协议,提供 ID Token(JWT),用来表达“用户是谁”
  • Client ID:ordinis-focus-web

    • 这是 client 的“唯一标识符”,会出现在各种协议参数里,例如:

    • /protocol/openid-connect/auth?...&client_id=ordinis-focus-web
  • Name: ordinis-focus-web

    • 一般可与 Client ID 一致,不参与协议核心校验
  • Always display in UI: On

    • 含义:是否在某些 Keycloak UI(例如账号管理页面)里展示这个 client,可开可不开
  • Next

image-20260112002601580

我们准备用子域名 focus.ordinis.dev 跑 Flask:

  • Client authentication:ON(Confidential client)
    • 含义:这个 client 是 Confidential Client,Keycloak 会生成 Client Secret ,并要求 client 在某些 token 交换环节 出示凭证(client secret 或私钥) 来证明“我是这个应用本人”。
    • 由于需要安全保存这个 Client Secret ,因此只有后端服务(Flask)建议开启这个模式。
    • 如果是 OFF,则对应的是 Public client,一般用于 不能安全保存 secret 的情景,如纯前端项目
  • Standard flow:ON(必须)

    • 含义:Standard flow = 启用 Authorization Code Flow,就是启用我们之前讲的那一套 “跳转网页认证流程“ 上加上 用 codetoken 这个步骤,具体在 第四板块会解释。
    • 前端跳到 Keycloak → 用户在 Keycloak 登录成功 → Keycloak 带着 code 重定向回你的 redirect_uri → 应用用 code 去 Keycloak 换 token(以及验证)
    • 常见跳转网页:/auth?...response_type=code&client_id=...&redirect_uri=...
  • Direct access grants:OFF(建议先不要开)

    • 含义:允许你的应用“直接拿用户名密码去 Keycloak 换 token”,俗称 password grant (ROPC)
    • 这会导致你的应用收集用户密码,破坏 IdP 的边界
  • Implicit flow:OFF

    • 含义:Implicit Flow 是 OAuth 2.0 在 早期为浏览器 SPA 设计的一种授权方式。Keycloak 直接把 token 放在 redirect URL 里返回,没有 authorization code,因此极度不安全。
  • Service accounts:OFF(此处不需要)

    • 含义:让 client 以“自己”的身份拿 token
    • 适用于机器对机器(M2M):例如一个后台任务调用另一个服务,不需要用户登录。开启后 Keycloak 会给这个 client 生成一个“服务账号用户”(service account user),client 用 client_id +secret 直接换取 access token(没有用户参与)
  • OAuth 2.0 Device Authorization Grant:OFF

    • 含义:这是 OAuth2 专门为**“没有键盘/浏览器的设备”**设计的登录方式。
    • 流程是这样的:
      1. 设备请求 Keycloak:“我想登录,但我没法弹浏览器”
      2. Keycloak 返回:user_code(给用户看的),verification_uri(让用户用手机/电脑访问)
      3. 用户在 另一台设备 上完成登录
      4. 原设备轮询 Keycloak,拿到 token
  • OIDC CIBA Grant:OFF

    • CIBA = 用户交互 ≠ 登录发起端,举个银行系统的例子:
    • ATM → Keycloak:“我想让用户 Alice 登录”,但是不跳转浏览器,而是 让 Keycloak 给 Alice 的手机 App 发通知:“是否确认本次登录?”,Alice 在手机上点「确认」,Keycloak 通过后台通道把 token 发给原 client
  • Next

image-20260112003340949

然后是 Login Setting:

  • Root URL: https://focus.ordinis.dev

    • 给 Keycloak 用来拼接一些相对路径、展示链接、默认回跳等的“基础 URL”
  • Home URLhttps://focus.ordinis.dev

    • Keycloak 在某些 UI 或流程里用作“回到应用首页”的链接
  • Valid redirect URIs: https://focus.ordinis.dev/*

    • 含义:Keycloak 只允许把用户带着 code/token 跳回这些地址
  • Valid post logout redirect URIs: https://focus.ordinis.dev/

    • 含义:用户在 Keycloak 或你的应用触发 logout 后,允许跳回的地址白名单
    • 类似 redirect URI 的安全机制,只不过用于退出登录后的回跳
  • Web origins: https://focus.ordinis.dev

    • 含义: CORS 的白名单(允许哪些前端 origin 直接用浏览器调用 Keycloak 的端点)
    • 浏览器同源策略:https://focus.ordinis.dev 的 JS 去请求 https://auth.ordinis.dev 会触发跨域
    • Keycloak 必须回 Access-Control-Allow-Origin: https://focus.ordinis.dev

    image-20260115041920590

然后点 Save 保存,保存后到:

  • Clientsordinis-focus-webCredentials
  • Client Authenticator:Client Id and Secret

    • client 在与 Keycloak 交换 token 时,用 client_id + client_secret 作为认证方式,属于 OAuth2 的客户端认证方式之一(另一类是私钥 JWT、mTLS 等)
  • Client Secret:即前面提到的 Keycloak 为 confidential client 生成的共享密钥

    • 主要用于:code → token 的交换(token endpoint)

    • Regenerate 会让旧 secret 立刻失效(所有使用旧 secret 的服务会登录/换 token 失败)

  • Registration access token:用于 动态客户端注册(Dynamic Client Registration) 的管理 token

    • 大多数项目不需要,用不到就忽略。

image-20260112003503139

复制这个 Client Secret 后面有用。

(四) Client 架构进一步详解

我们在前面讲到了,Client 对应的是一个具体的应用服务。

但是我们之前讲的是 “一个应用如 Ordinis Focus 对应一个 Client” ,这其实是有失偏颇的。

我们必须要澄清的一个点:Keycloak 里的一个 Client,定义的是 Keycloak 和某个具体的对象之间的交互逻辑。

如果这个项目前后端分离,且前端和后端和 Keycloak 都有设计交互,那么本质上就要为 Keycloak 设计两套交互逻辑,分别对应前端和后端,因此要做两个 Client。如果前端和后端只有一方有做和 Keycloak 的交互,那么只用做一个 Client 即可。


架构 A:SPA(纯前端)模式

我们之前介绍的就是这种模式,具体流程如下:

1. 用户访问 focus.ordinis.dev
2. 浏览器被重定向到 Keycloak
3. 用户登录成功
4. Keycloak 直接把 access_token 返回给浏览器
(例如在 URL fragment 里)
5. 浏览器拿 access_token 调后端
6. 后端验证 JWT, 返回用户信息

这就是我们之前创建 Client 的时候所提到的 Implicit flow

但是这种流程中,Access_token 会出现在浏览器返回的 URL 片段(fragment)里(#access_token=...

这在很多情况下是极其不安全的,因为相当于把 token 放进了历史数据中,也被某些代理/网关/监控产品完整记录。


而我们开启 Standard flow:ON 之后,就会新增一道验证机制:Code Flow。此时在登录完成之后, Keycloak 返回的 URL 中不再包含 access_token,而是一个临时的 code

https://focus.ordinis.dev/callback?code=ABC123

然后 前端SPA 用这个 授权响应(code),再去 Keycloak 的 token endpoint 换取 access_token

同时,这个过程中加上了PKCE 验证机制:使用 code 换取 access_tokenn 的主体,必须要是 申请 access_token 的那一个浏览器实例。因此攻击者就算拿到了 code,也没办法用 这个 code 去兑换 access_token。这是因为在 PKCE 机制中, 浏览器实例 申请 code 时,会在本地先 生成一个 code_verifier,这个东西从未出现在网络请求中,攻击者无法获得。

注意:Code Flow 并不能解决 XSS/恶意插件 的攻击类型。这是因为 加上 code flow 之后,SPA 最后还是会用 code 去换取 token ,然后把 token 保存在本地。想要彻底解决 token 暴露在前端的问题,只能采用纯后端 架构(BFF)

Code Flow 能防止的攻击是:Authorization response interception / code injection(授权响应劫持/注入)

就是指能获取 URL 历史记录的恶意应用/扩展。

对应的 Client 配置

  • Client authentication = OFF(Public)
  • Standard flow = ON(Authorization Code)
  • PKCE = S256(必须)
  • Valid redirect URIs 包含:https://focus.ordinis.dev/callback
  • Web origins 包含:https://focus.ordinis.dev(或 +

完整流程如下

image-20260112201403428

  1. 访问 https://focus.ordinis.dev/

  2. 返回该页面的 基本 JSS,HTML,CSS

  3. 浏览器本地生成 code_verifier, code_challenge, statenonce

  4. 浏览器重定向到 Keycloak 的 Authorization Endpoint

    https://auth.ordinis.dev/realms/Ordinis/protocol/openid-connect/auth

    并带查询参数(示例):

    • client_id=ordinis-focus-spa
    • redirect_uri=https://Ffocus.ordinis.dev/Fcallback
    • response_type=code
    • scope=openid%20profile%20email
    • state=RANDOM_STATE
    • nonce=RANDOM_NONCE
    • code_challenge=...
    • code_challenge_method=S256
  5. Keycloak 检查 redirect_uri 是否允许、是否启用标准流等

  6. Keycloak 返回登录页资源(HTML/CSS/JS)

  7. 浏览器把 用户名密码 表单/凭据提交给 Keycloak

  8. Keycloak 验证用户身份(密码、OTP、社交登录等)

    • 然后准备把用户送回你的 redirect_uri
    • 此时Keycloak 内部有了用户会话(SSO),浏览器端还没有 token
  9. Keycloak 返回 302,并附带 code 让浏览器跳转到:

    GET https://focus.ordinis.dev/callback? code=AUTH_CODE & state=RANDOM_STATE

    在此过程中,浏览器SPA 在 /callback 页面解析 URL:取出 code,取出 state 并校验是否等于本地保存的 state,仍然没有 token

  10. SPA 在浏览器发起 HTTP 请求到Keycloak token endpoint 换取 token

    POST https://auth.ordinis.dev/realms/Ordinis/protocol/openid-connect/token

    使用参数:

    • grant_type=authorization_code
    • client_id=ordinis-focus-spa
    • code=AUTH_CODE
    • redirect_uri=https://focus.ordinis.dev/callback
    • code_verifier=THE_VERIFIER_FROM_STEP1
  11. Keycloak 校验 code 是否有效、是否未使用、是否属于该 client,并用 保存的 code_challenge 与你现在发来的 code_verifier 做 PKCE 校验。校验通过后,以 json 形式 签发 token

  • access_token(JWT,给后端 API 用)
  • id_token(JWT,给 SPA 确认用户身份/展示用)
  • refresh_token(可选;Keycloak 对 SPA 是否给 refresh token 取决于配置与安全策略)
  • expires_in
  1. SPA 用 access_token(通常内存;或 sessionStorage;不建议 localStorage)调用你的后端 API

    HTTP Header:Authorization: Bearer <access_token>

  2. 后端只做离线验证(不需要请求 Keycloak):

    • 校验 JWT 签名(用 Keycloak 的 JWKS 公钥)
    • 校验 iss(issuer)= https://auth.ordinis.dev/realms/Ordinis
    • 校验 aud(受众)是否符合你的 API 期望
    • 校验 exp 未过期
    • 从 token 取 sub(Keycloak userId)

    然后返回前端渲染所需要的业务信息

一句话概括:

  • 浏览器首先访问后端获取基本页面,用户点击“登录”按钮后,前端生成一些验证数据,并组合成一个 URL ,浏览器用这个 URL 去访问 Keycloak。
  • 用户在 Keycloak 登录成功后,Keycloak 记录一个SSO session,处理之前 前端生成的一些验证数据,并生成一个 codestate,组合成一个URL发给前端。
  • 前端收到 codestate,校验 state 通过后,用 code 和其他的一些验证信息去请求 Keycloak,获取 token
  • Keycloak 验证 code 和其他信息后返回 token,前端再用 token 去调取后端数据库的更多信息

简化的流程

[ Browser ]  <--OIDC-->  [ Keycloak ]
|
| access_token
v
[ Backend API ] (只验 JWT)
  • Client:ordinis-focus-web

此结构下,后端不和 Keycloak 交互,除非如果你后端想做 token introspection 或者 主动拉取用户资料(UserInfo endpoint),那才会后端→Keycloak。


思考:后端需要的 user_id 是否在 access token 里?

是的, user_idaccess_tokenaccess_token 是一个 JWT,其 sub(subject) 字段就是 Keycloak 内部的 user id。典型 payload(简化):

{
  "iss": "https://auth.ordinis.dev/realms/Ordinis",
  "sub": "c1a9c7b0-8b6f-4e7b-9e51-xxxxxx",
  "aud": "ordinis-focus-spa",
  "exp": 1710000000,
  "iat": 1709996400,
  "scope": "openid profile email"
}

思考:后端既然不和 Keycloak 交互,如何确认 token 的安全?

后端验证 access_token 的完整逻辑是:

  • Keycloak 用 私钥 给 JWT 签名,后端用 Keycloak 的公钥 验证签名

  • 公钥来源:

    https://auth.ordinis.dev/realms/Ordinis/protocol/openid-connect/certs

此外还会验证

  • iss === https://auth.ordinis.dev/realms/Ordinis
  • audience
  • 过期时间

思考:第 9 步为什么一定要跳转?不能不跳转吗

表面看你的想法是对的:“反正现在还没有 token,也没业务数据,跳转好像没意义”

第 9 步的跳转:Keycloak → redirect_uri(你的域),其意义不是渲染页面,而是:把控制权从 Keycloak 的安全域,交还给你的应用域

  • 在这之前:浏览器受 Keycloak 控制,Cookie / session 属于 Keycloak

  • 在这之后:浏览器回到 focus.ordinis.dev,你的 SPA 才能继续流程

  • 同时,state 校验必须发生在“你的域”:

其次,OAuth2 协议规定:code 只能通过 redirect_uri 交付

  • authorization code 只能:通过 redirect_uri 的 query 参数返回
  • 不能通过:XHR,postMessage,iframe response

这是为了防止:第三方脚本窃取 code,CSRF / clickjacking



架构 B:BFF(后端代理登录)模式

这是最安全的框架,不用担心 token 泄露

简化的流程框架如下:

[ Browser ]
|
| session cookie
v
[ Backend ] <--OIDC--> [ Keycloak ]
  • Client:ordinis-focus-backend
  • 类型:Confidential,后端用 code + client_secret 去 token endpoint 换 token
  • Keycloak 登录后回到你的站点(callback)

配置

  • Standard flow:ON(必须)
  • Client authentication:ON(必须)
  • Valid redirect URIshttps://focus.ordinis.dev/auth/callback
  • Web origins:通常对 BFF 不敏感(因为不是浏览器直接调 token),但如果你的前端会直接调 Keycloak 其它端点再说
  • Direct access grants:OFF(建议,避免 password grant)
  • Service accounts:通常 OFF(你这是用户登录,不是机器对机器)
  • (可选但推荐)启用 PKCE:Keycloak 支持对 confidential client 也要求 PKCE(看版本/策略),启用后更抗“授权响应被截获”的一类问题。

详细流程如下:

  • 浏览器(Browser):用户的 UA,只和你的后端交互;不直接拿 Keycloak token
  • 后端(Backend / BFF)https://focus.ordinis.dev
  • Keycloakhttps://auth.ordinis.dev
  • 回调地址https://focus.ordinis.dev/auth/callback(示例)
  • 后端会话focus_session(HttpOnly + Secure + SameSite)

image-20260113222925594

  1. 浏览器访问后端:GET https://focus.ordinis.dev/,获得 基本页面资源

  2. 浏览器访问后端 GET https://focus.ordinis.dev/auth/login,触发登录:

    • 后端生成并保存“临时登录状态”(非常关键):state(防 CSRF/回调注入)、nonce(绑定 id_token 防重放)
    • 后端 保存这些 临时数据,并关联一个临时 cookie 或临时 session id:例如:login_tx=abc123(HttpOnly cookie),用来在回调时查到 state/nonce/verifier
  3. 后端返回 302 Redirect 到 Keycloak Authorization Endpoint:

    • 302 Location(示例):浏览器拿到 302,然后自动跳转去 Keycloak。

      https://auth.ordinis.dev/realms/Ordinis/protocol/openid-connect/auth
      ?client_id=ordinis-focus-backend
      &redirect_uri=https%3A%2F%2Ffocus.ordinis.dev%2Fauth%2Fcallback
      &response_type=code
      &scope=openid%20profile%20email
      &state=RANDOM_STATE
      &nonce=RANDOM_NONCE
      (可选) &code_challenge=...
      (可选) &code_challenge_method=S256

​ 注意:在 BFF 模式下,这一步仍然是“浏览器跳 Keycloak”。区别是:后续 code 换 token 是后端做,不是浏览器做。

  1. Keycloak 收到 browser 的访问请求,校验 client_id 是否存在,redirect_uri 是否在该 client 的允许列表里,通过后 返回登录页资源(HTML/CSS/JS)

  2. 用户提交用户名密码/OTP:浏览器 POST 到 Keycloak 的登录 action(细节由 Keycloak 管),Keycloak 认证成功后建立 Keycloak SSO Session(Keycloak 侧 cookie)

  3. Keycloak 返回 302 跳转请求到 后端( redirect_uri ),把 code 发回你的后端 callback

    https://focus.ordinis.dev/auth/callback?code=AUTH_CODE&state=RANDOM_STATE
  4. 浏览器跟随跳转GET https://focus.ordinis.dev/auth/callback?code=...&state=...,向后端递交 code 和 state

  5. 后端处理 callback:

    • 校验 state:从请求里拿 state,用 login_tx取出当初生成的 expected_state,是否匹配
    • 用 code 换 token:后端调用 Token Endpoint
    • 请求:

      POST https://auth.ordinis.dev/realms/Ordinis/protocol/openid-connect/token
      Content-Type: application/x-www-form-urlencoded
    • body(confidential client 标准):

      • grant_type=authorization_code
      • client_id=ordinis-focus-backend
      • client_secret=...关键:只在后端
      • code=AUTH_CODE
      • redirect_uri=https://focus.ordinis.dev/auth/callback
      • (如果你启用 PKCE)code_verifier=...
  6. Keycloak 收到请求,校验

    • code 是否存在、未过期、未使用、是否签发给该 client_id
    • redirect_uri 是否与签发时一致

    • client_secret 是否正确

    • (若启用 PKCE)code_verifier 是否匹配当初的 code_challenge

  7. Keycloak 返回 JSON 给后端:

    • access_token(JWT,给后端调用资源/或给后端做授权判断)
    • id_token(JWT,描述登录用户身份)
    • refresh_token(如果允许离线/刷新)
    • expires_in
  8. 后端从 id_tokenaccess_token 里取 sub(Keycloak userId)email/preferred_username 等,然后用 sub 查业务数据库,获取进一步信息。

  9. 后端生成 focus_session(随机 session id 或你自签 JWT),并设置 cookie:

    • Set-Cookie: focus_session=...; HttpOnly; Secure; SameSite=Lax(or Strict); Path=/
    • 302 到应用首页:Location: /,或者返回一个“登录完成页”HTML
  10. 浏览器用 cookie 调后端;后端用“自己的会话”鉴权

  • 请求:GET https://focus.ordinis.dev/api/...
  • 自动带 cookie:focus_session=...
  • 后端验证 session:
    • 查 session store / 验证你自签 JWT
    • 拿到用户 id / sub / 权限
    • 返回业务数据

一句话概括:

  • 浏览器首先访问后端获取基本页面,用户点击“登录”按钮后,后端生成一些验证数据,并组合成一个 URL 发给前端,让前端用这个 URL 去访问 Keycloak。
  • 用户在 Keycloak 登录成功后,Keycloak 记录一个SSO session,处理之前 后端生成的一些验证数据,并生成一个 codestate,组合成一个URL发给前端。前端用这个 URL 去访问后端
  • 后端收到 codestate,校验 state 通过后,用 codeclient_secret 去请求 Keycloak,获取 token
  • Keycloak 验证 code 后返回 tokentoken 中包含了后端需要的一切信息

在这种架构下,攻击者最多只能:在用户会话存续期间,借用户 cookie 发请求做操作(CSRF/XSS 结合)

按照当前我们对 ordinis-focus-web 这个 Client 的 设置,实际上采用的就是 架构B,而不是 架构 A。


架构 C:前端 + 后端都和 Keycloak 交互

[ Browser ]  <--OIDC-->  [ Keycloak ]
|
| access_token
v
[ Backend API ] <--client_credentials / UMA--> [ Keycloak ]

Keycloak Client 数量:2 个

Client角色
ordinis-focus-web用户登录
ordinis-focus-api服务身份 / 授权

几个容易混淆的概念:

  • code中间凭证(一次性)

    • 功能: 证明Keycloak 已经成功给“某个 Client”完成了一次用户登录

    • 特点:只能用 一次,有极短有效期,不能直接访问 API,不能包含用户权限,必须再去 Keycloak 换 token

    • 当你用 codeKeycloak 的 token endpoint 兑换时,Keycloak 会返回一个 JSON:

      {
        "access_token": "...",
        "id_token": "...",
        "refresh_token": "...",
        "expires_in": 300
      }
      
  • access_token:给“资源服务器”(你的后端 API)用的

    • 本质:JWT

    • 用途: HTTP 的请求头 Authorization: Bearer access_token

    • 后端关心:签名,iss,aud,sub 👉 你前面设计的“后端只认 JWT,不认 Keycloak”完全就是围绕它。

  • id_token:给“Client 自己”用的

    • 表示:“这个用户是谁,他刚刚完成了一次登录”

    • 包含:sub,email,name,auth_time

  • client_secret:用来证明“来换 token 的这个东西,真的是那个 Client”,不参与用户认证

    • 只有在“Client 在后端”的情况下,client_secret 才存在


(五)部署 Flask 最小服务(OIDC 登录)

在服务器上:

pip install Flask Authlib requests

创建 app.py

import os
import secrets
from functools import wraps

from flask import Flask, redirect, url_for, session, request, jsonify
from authlib.integrations.flask_client import OAuth



# === 环境变量 ===
# Keycloak 的基地址(通常是 Keycloak 的外网入口域名)
KEYCLOAK_BASE = os.environ.get("KEYCLOAK_BASE", "https://auth.ordinis.dev")
# Realm 名称(Keycloak 区分不同租户/应用空间的最核心概念)
REALM = os.environ.get("KEYCLOAK_REALM", "Ordinis")  # 注意大小写
# OIDC Client ID:你在 Keycloak 里创建的 Client 的 "Client ID"
CLIENT_ID = os.environ.get("KEYCLOAK_CLIENT_ID", "ordinis-focus-web")
# Client Secret:Keycloak Client 的 secret
CLIENT_SECRET = "mAbFlGgIKg85xnB1dNnv5r9UdKTDNrqX"
# 应用对外的基地址(用于生成 redirect_uri)
APP_BASE_URL = os.environ.get("APP_BASE_URL", "https://focus.ordinis.dev")



# ======= Flask App 初始化 ================
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "this is secret key")
# Flask 用于签名 session cookie 的密钥(非常关键)
# session 内容本质上存在客户端 cookie 里(Flask 默认是 signed cookie),


# ======= Session Cookie 安全策略 =======
# 开发期为了 localhost 回跳更稳:Lax 足够;HTTPS 上线再改 secure=True
app.config.update(
    SESSION_COOKIE_HTTPONLY=True,    # JS 无法读取 cookie(防止 XSS 直接偷 session)
    SESSION_COOKIE_SAMESITE="Lax",   # Lax: 允许“顶层导航 GET 跳转”携带 cookie
    # OIDC 的授权回跳一般是浏览器顶层跳转,因此 Lax 通常可用
    SESSION_COOKIE_SECURE=True,      # 仅允许 HTTPS 传输 cookie
)

# ========  Authlib 初始化 + 注册 OIDC Client ======
# OAuth(app) 会把 OAuth Client 能力绑定到 Flask app 上,
# 并使用 Flask session 来暂存授权流程需要的数据(如 state/nonce 等)
oauth = OAuth(app)


oidc = oauth.register(    # register(...) 注册一个远端 OIDC Provider(Keycloak):
    name="keycloak",      # 给这个 provider 起个名字(内部引用用)
    client_id=CLIENT_ID,
    client_secret=CLIENT_SECRET,
    # OIDC discovery:Keycloak 标准地址,打开它通常能看到 issuer、端点、jwks_uri 等关键信息
    server_metadata_url=f"{KEYCLOAK_BASE}/realms/{REALM}/.well-known/openid-configuration",
    # openid 必须有(否则不是 OIDC,只是 OAuth2)
    client_kwargs={"scope": "openid profile email"},
    # profile/email 是常见的用户基础信息 scope
)


# ========= “会话鉴权”中间件 =======
# 它是一个装饰器工厂:输入一个 view 函数 f,输出一个包装后的 wrapper
# wrapper 每次请求都会先检查 session 里有没有 "user"
# 没有:认为未登录 → 重定向到 /login
# 有:放行 → 执行原函数 f
def login_required(f):
    @wraps(f)  # 不加 @wraps,所有被装饰的函数名字都会变成 wrapper
    def wrapper(*args, **kwargs):
        if "user" not in session:
            return redirect(url_for("login", next=request.path))
            # url_for("login", next=request.path) 会把你原来想访问的路径带过去
            # 例如访问 /me 未登录,会跳到:/login?next=/me
            # 登录完成后再跳回 /me(你在 /login 里把这个 next 存到 session 了)
        return f(*args, **kwargs)
    return wrapper
# 你现在的鉴权不是“验证 token”,而是“验证 session 是否含 user”。
# 这在 BFF 模式非常典型:token 在后端,浏览器只持有会话 cookie。



# =========== 定义 API ==========
# 首页:展示状态 + 提供入口
@app.get("/")
def index():
    # 如果session 中用户未登录
    if "user" not in session: 
        return "<h1>Focus</h1><p>Not logged in</p><a href='/login'>Login</a>"
    # 如果session 中用户已登录
    u = session["user"]
    return (
        "<h1>Focus</h1>"
        f"<p>Logged in as {u.get('email') or u.get('preferred_username') or u.get('sub')}</p>"
        "<a href='/me'>/me</a> | <a href='/logout'>Logout</a>"
    )

# /login:启动 OIDC Authorization Code Flow(带 nonce)
@app.get("/login")
def login():
    redirect_uri = f"{APP_BASE_URL}/callback" # 这是 Keycloak 登录完成后回跳到你 BFF 的地址

    # session["post_login_redirect"] 存储“登录后要去哪里”
    session["post_login_redirect"] = request.args.get("next") or "/"
    # 风险点(生产必须处理):next 不能完全信任(见后面“开放重定向”问题)

    # 1) 生成并保存 nonce(OIDC 必需)
    # nonce 用于绑定“这次授权请求”和“回来的 id_token”,防止攻击者把别人的 id_token 重放给你
    nonce = secrets.token_urlsafe(16)
    session["oidc_nonce"] = nonce

    # 2) 发起授权请求时带上 nonce
    return oidc.authorize_redirect(redirect_uri, nonce=nonce)
    # 1. 从 discovery 文档里拿到 authorization_endpoint
    # 2. 生成并保存 state(CSRF 防护)到 session
    # 3. 组装授权 URL(含 client_id, redirect_uri, scope, state, nonce 等)
    # 4. 返回一个 302,让浏览器跳转到 Keycloak

# /callback:用授权码换 token,并建立本地 session
@app.get("/callback")
def callback():
    token = oidc.authorize_access_token()
    # 这行代码从请求参数中取出 code 与 state,验证 state 后,向 keycloak 获取 token

    # 3) 回调时取出 nonce 做校验与解析
    nonce = session.pop("oidc_nonce", None) # pop:读取后删除,因为 nonce 是一次性的
    userinfo = oidc.parse_id_token(token, nonce=nonce) # 从 token 中获取用户信息
    # 用 Keycloak 的 jwks_uri 下载公钥(或缓存)验证 id_token 签名,解析出 claims,作为 userinfo

    session["user"] = dict(userinfo) # 把 userinfo 写进 session
    # 从此开始,你的“登录态”不依赖前端 token,而依赖服务端 session:
    session["access_token"] = token.get("access_token") # 保存 access_token
    # 这是为了后端将来调用 Keycloak Admin API 和 你的资源服务器 API
    return redirect(session.pop("post_login_redirect", "/")) # 登录完成后跳转回用户原来要访问的路径
    # 同样用 pop 读完删除,避免污染下一次登录流程

# /me:受保护资源示例
@app.get("/me")
@login_required # 挡住未登录请求
def me(): # 已登录则返回 session 内的用户信息(claims)
    return jsonify(session["user"]) 

# /logout:最小登出(只清本地会话)
@app.get("/logout")
def logout():
    session.clear() # /logout:最小登出(只清本地会话)
    # 最小版:只清本地 session,但不会:清除 Keycloak 那边的 SSO 会话
    return redirect(url_for("index"))
# 所以用户点 Logout 后,如果再次点 Login,可能会“秒进”登录成功(因为 Keycloak 仍然记得他登录过)。
# 要做真正 SSO logout,需要:
# 跳转到 Keycloak 的 end_session_endpoint(并带 id_token_hint / post_logout_redirect_uri)
# 或者使用 front-channel/back-channel logout 机制(Keycloak 支持)


# /healthz:健康检查端点
# 给 Nginx / k8s / 监控探针用
# 不应依赖登录态,也不应做外部网络调用(否则容易误报)
@app.get("/healthz")
def healthz():
    return "ok", 200

if __name__ == "__main__":
    app.run("0.0.0.0", 5000, debug=True)

设置环境变量(把 secret 换成你自己的):

export OIDC_CLIENT_ID="ordinis-focus-web"
export OIDC_CLIENT_SECRET="你从Keycloak复制的client secret"
export KEYCLOAK_REALM="ordinis"
export KEYCLOAK_BASE="https://auth.ordinis.dev"
export APP_BASE_URL="https://focus.ordinis.dev"
export FLASK_SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"

启动:

python app.py

到这一步,Flask 在 127.0.0.1:5000 工作正常即可。


(五)Nginx

在 Nginx 新增一个 server(示例),核心是把外部请求转到 127.0.0.1:5000,并带上正确的转发头(Keycloak 的 --proxy-headers=xforwarded 依赖这些头)。(Keycloak)

创建站点:/etc/nginx/sites-available/focus.ordinis.dev

# 可选:HTTP 80 直接跳转到 HTTPS
server {
listen 80;
server_name focus.ordinis.dev;
return 301 https://$host$request_uri;
}

# HTTPS:Cloudflare -> Nginx(Origin Cert) -> Flask
server {
listen 443 ssl http2;
server_name focus.ordinis.dev;

ssl_certificate /etc/nginx/ssl/ordinis.dev/origin-cert.pem;
ssl_certificate_key /etc/nginx/ssl/ordinis.dev/origin-key.pem;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;

# 关键:反代给 Flask
location / {
proxy_pass http://127.0.0.1:5000;

# 让 Flask 知道外部是 https + 正确 host
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# 避免某些重定向/大 header 问题
proxy_buffering off;
proxy_http_version 1.1;
proxy_read_timeout 60s;
}
}

重载:

sudo ln -s /etc/nginx/sites-available/focus.ordinis.dev /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

然后去 cloudflare 里加上DNS:

在 Cloudflare → ordinis.dev → DNS 里新增一条:

  • Type: CNAME
  • Name: focus
  • Target: ordinis.dev (或你的源站公网 IP 也行,用 A 记录)
  • Proxy status: Proxied(橙云,建议)
  • TTL: Auto

然后再为 auth.ordinis.dev 也做同样的(如果你 Keycloak 是 auth.ordinis.dev)。

如果你已经有 ordinis.dev 指向源站,给 focus 做 CNAME 到 ordinis.dev 是最省事的做法。