Android SSO 与 App-to-App 登录流程深度解析
前言:SSO 是什么
SSO(Single Sign-On,单点登录)本质上是一种票据中转机制:用户在公司统一登录页输入密码,登录页生成一张一次性凭证(ticket),App 拿到这张票去服务器换取真正的身份 Cookie。全程用户的密码只交给公司统一登录页,App 本身永远看不到密码。
一、参与方
在理解流程之前,先明确有哪些系统参与了这件事:
| 角色 | 是什么 | 地址(生产环境) |
|---|---|---|
| SSO 服务 | 公司统一登录页,负责验证密码并颁发 ticket | https://sso.example.com |
| UserService | 公司内部用户体系服务,负责用 ticket 换 Cookie | https://userservice.example.com |
| MyApp App | 公司员工用的内部 Android App | — |
| AuthCallbackServer | App 内部在 127.0.0.1 上开的临时 HTTP 服务器 | http://127.0.0.1:{随机端口}/callback |
| 系统浏览器(Chrome 等) | 用来打开 SSO 登录页,让用户输密码 | — |
二、完整时序图(文字版)
1 | 用户 App(UI) AuthViewModel AuthRepository 系统浏览器 SSO服务 UserService |
三、逐步拆解每个环节
第 1 步:用户点击按钮
LoginScreen.kt 中的 SSO 登录按钮绑定了 onSsoLoginClick 回调,点击后调用:
1 | authViewModel.startSsoLogin(subsystemAlias = "userservice") |
subsystemAlias 固定为 “userservice”,用于告知 SSO 服务和 UserService 这次登录属于哪个子系统。
第 2 步:并发保护检查
startSsoLogin 第一件事就是防止重复触发:
1 | fun startSsoLogin(subsystemAlias: String) { |
“忙碌”的定义是:当前状态是 LaunchingLogin、HandlingCallback、LaunchingImAuthorization、HandlingImCallback 其中任意一种。也就是说,从点击按钮到登录完成的整个过程中,再次点击都会被静默忽略。
第 3 步:准备阶段——在本机开一个 HTTP 服务器
这是 SSO 流程最特殊的地方,也是和 IM 登录最大的区别。
authRepository.prepareSsoLogin(“userservice”) 做了这几件事:
3a. 关闭旧服务器(防止上次未正常关闭留下的残留)
1 | callbackServer?.close() |
3b. 创建新的 AuthCallbackServer
1 | val server = AuthCallbackServer() |
AuthCallbackServer 的构造函数:
1 | private val serverSocket = ServerSocket( |
为什么要在本机开服务器?
SSO 的安全机制要求 ticket 必须被”redirect”回一个预先登记的地址(service 参数)。桌面端可以用 localhost,移动端也用同样的思路——在本机开一个临时 HTTP 服务器监听回调。
3c. 构造 SSO 登录 URL
1 | fun buildLoginUrl(callbackUrl: String, subsystemAlias: String): String { |
最终 URL 长这样(生产环境):
1 | https://sso.example.com |
这个 URL 告诉 SSO 服务:用户登录成功后,把 ticket 带到 http://127.0.0.1:54321/callback 这个地址。
第 4 步:打开系统浏览器
prepareSsoLogin 返回 URL 后,ViewModel 通过 Channel 发出副作用:
1 | _ssoEffect.send(SsoEffect.LaunchUrl(url)) |
LoginScreen.kt 中的 LaunchedEffect 收到后:
1 | is SsoEffect.LaunchUrl -> { |
此刻 App 切到后台,系统浏览器打开了 https://sso.example.com?service=...&subsystem=userservice。
💡 深入:startActivity 打开浏览器的底层机制
Intent.ACTION_VIEW 配合 https:// URL,Android 系统会查询已安装 App 中哪些声明了处理 https scheme 的 Activity(通常是 Chrome 或系统默认浏览器),然后启动它。这和你在手机上点一个网页链接完全一样——App 本身不渲染页面,只是把 URL 丢给系统,自己退到后台。
此时 App 进入后台,但协程仍在 Dispatchers.IO 线程上挂起等待 ServerSocket.accept(),App 进程并没有被销毁。
📦 深入:TCP 只管传输,不管消费——为什么必须主动 read()?
一个常见误解:「连接建立了,数据不就被消费了吗?」——并不是。
TCP 做的事只是:把字节从发送方的内核发送缓冲区,搬到接收方的内核接收缓冲区。搬完之后 TCP 的工作就结束了。数据躺在接收缓冲区里,等应用程序来主动取。
类比:TCP 是快递员,接收缓冲区是门口的快递柜。快递员把包裹放进柜子之后,你得自己去开柜取包裹——快递员不会帮你搬进房间,也不知道你有没有取。柜子满了他就没地方放新包裹,只能在外面等(TCP 流量控制)。
所以 accept() 只是「接受了连接」,数据还在缓冲区里:
这也是 headers 必须全部读完的根本原因:如果缓冲区里还有未读的 headers,缓冲区可能满,TCP 流量控制会让浏览器的发送窗口缩为 0,发送方卡住等接收方腾空间,而接收方又在等浏览器读响应——双方互相等待,死锁。读完所有 headers 是消除这个隐患的防御性写法。
第 5 步:用户在浏览器输入账号密码
这一步完全在浏览器内发生,App 对此一无所知,也看不到任何密码。
SSO 服务验证密码成功后,做两件事:
生成一个一次性 ticket(例如 ST-12345-abcdef-sso)
把浏览器 redirect 到:
1 | http://127.0.0.1:54321/callback?ticket=ST-12345-abcdef-sso |
这个 redirect 实际上是浏览器发起的一次 HTTP 请求,目标是本机的 ServerSocket。
💡深入:302 重定向到本机的完整过程
这里没有任何魔法,就是最普通的 HTTP 302 重定向。
用户点击「确认登录」后,浏览器向 SSO 服务提交表单(POST 请求)。SSO 服务验证密码正确后,不返回 200,而是返回 302:
浏览器收到 302,会自动向 Location 里的地址发起一个新的 GET 请求:
这个请求的目标是本机 127.0.0.1,走的是回环网卡(loopback),不经过任何网络,直接被同一台设备上正在 accept() 的 ServerSocket 接住。浏览器完全不知道对面是一个 Android App,它只认 HTTP 协议——只要对方在那个端口上,能建立 TCP 连接、返回合法的 HTTP 响应,浏览器就会处理它。
第 6 步:AuthCallbackServer 接收 ticket
就在用户登录的同时,App 在 Dispatchers.IO 上挂起等待:
1 | override suspend fun awaitSsoCallback(): Result<Unit> { |
WakeLock 的作用:Android 系统可能在屏幕关闭时让 CPU 进入休眠,导致 ServerSocket.accept() 永远不会被唤醒。PARTIAL_WAKE_LOCK 让 CPU 保持运行,超时设置 6 分钟(略长于服务器的 5 分钟超时)。
awaitCallback() 内部的 HTTP 解析过程:
1 | // 1. 读取请求行 |
💡深入:ServerSocket 为什么能控制浏览器显示什么?
浏览器向 127.0.0.1:54321 发出 GET 请求后,会等待对方返回 HTTP 响应,再把响应 body 渲染出来。AuthCallbackServer 里的 sendResponse() 手动拼了一个合法的 HTTP 响应:
浏览器收到这个响应,看到 Content-Type: text/html,就按 HTML 把 body 渲染出来——也就是用户看到的「登录成功,正在返回 myapp…」页面。AuthCallbackServer 扮演的角色就是一个只活一次的微型 HTTP 服务器,接完 ticket 就关门,顺手把成功页发给浏览器。
为什么 headers 必须全部读完? TCP 是双向流。浏览器把请求发完后,要等服务端把请求全部读走,才会开始等响应。如果 App 不消耗完 header 行就直接写响应,可能触发 TCP 流量控制,双方互相等待(死锁)。所以代码里有那段 while 循环,必须一行一行读到空行才能写响应。
callbackUrl 为什么要带回去? UserService 的 internal_login 接口需要用它做二次校验——确认 ticket 确实是颁发给这个地址的,防止 ticket 被其他人截获后拿来冒用。
第 7 步:用 ticket 换 Cookie(exchangeTicket)
这是整个流程的核心兑换步骤:
1 | POST https://userservice.example.com/auth/internal_login |
UserService 收到请求后:
拿着 ticket 去 SSO 服务验证(这是服务端对服务端的校验,客户端看不到)
验证 validateService 与当初颁发 ticket 时记录的 service 一致
查出对应的用户信息
在 HTTP 响应头里下发 Set-Cookie,在 响应体里返回用户数据
响应示例:
1 | HTTP/1.1 200 OK |
兼容性处理:部分 UserService 环境只在 JSON 里返回 access-token,不下发 Set-Cookie。CookieCodec.withAccessTokenFallback 会把 JSON 里的值补合成一条标准格式的 Set-Cookie:
1 | // 如果 Set-Cookie 里没有 access token,就自己造一条 |
用户信息解析:兼容两种响应格式
internal_login:data.name、data.email 直接在 data 下
user_info(会话恢复时用):data.internalUser.name、data.internalUser.email 在嵌套层
第 8 步:持久化登录态
凭据换取成功后:
1 | // 1. 把 Cookie 列表和用户信息存入 EncryptedSharedPreferences |
存储的 key 一览:
1 | "sso_cookies_json" → JSON 序列化的 Cookie 字符串列表 |
第 9 步:状态回到 Idle,UI 跳转主界面
1 | _ssoLoginState.value = SsoLoginState.Idle |
LoginScreen 收到状态变化,跳转主界面。整个 SSO 流程结束。
四、各种失败场景的处理
场景 A:用户 5 分钟内没有完成登录
ServerSocket.soTimeout = 5 * 60 * 1000,超时后 accept() 抛出 SocketTimeoutException,被 runCatching 捕获,状态切为 Error,显示”登录失败”。
场景 B:用户在 SSO 页面点了取消/关闭了 WebView
cancelSsoLoginIfWaiting() 被 UI 调用:
1 | fun cancelSsoLoginIfWaiting() { |
场景 C:ticket 已过期或被用过(一次性)
UserService 返回非 2xx,error(“internal_login failed: ${response.code}”) 被 runCatching 捕获,状态切为 Error。
场景D:协程被取消(App 进后台被系统杀死等)
CancellationException 被单独捕获,状态回 Idle 并重新 throw,确保协程取消语义正确:
1 | } catch (e: CancellationException) { |
五、下次启动如何恢复登录态(restoreSession)
App 不是每次都要走完整的 SSO 流程。下次启动时:
1 | 1. 从 EncryptedSharedPreferences 读出 cookies + user |
旧版本兼容:如果本地只有旧二维码凭据(gateway_url 有值但 sso_cookies_json 为空),说明是从旧版本升级来的用户,直接清除并强制重新登录。
六、一句话总结每个类的职责
| 类/文件 | 职责 |
|---|---|
| LoginScreen.kt | 按钮点击 → 调 ViewModel;收 SsoEffect → 打开浏览器 |
| AuthViewModel | 状态机控制;协程生命周期管理;并发保护 |
| AuthRepositoryImpl | 实现所有登录逻辑;持久化;协调各 API 类 |
| AuthCallbackServer | 在 127.0.0.1 开临时 HTTP 服务器,等浏览器回调,提取 ticket |
| AuthApi | 封装两个 UserService HTTP 接口:internal_login(换 Cookie)和 user_info(校验 Cookie) |
| AuthEnvConfig | 管理不同环境(PROD/BETA/SIT)的服务地址和 Cookie 名 |
| CookieCodec | Set-Cookie 字符串的编解码工具;兜底合成缺失的 access token Cookie |
| SsoLoginState | 登录流程的状态枚举 |
| SsoEffect | UI 副作用通道(LaunchUrl / LaunchExternalUrl) |
七、IM 登录(App-to-App)完整流程
与 SSO 登录的本质区别
SSO 登录的回调靠的是 HTTP redirect → 本机 ServerSocket,整个过程在当前 App 进程里闭环。
IM 登录完全不同:App 跳出去,IM App 完成授权,再用 deep link 把 token 直接带回来。没有 ServerSocket,没有 HTTP 服务器,中间 App 可以被系统暂停甚至进程重启,token 依然能安全回来。
第 1 步:用户点击「IM 登录」
1 | authViewModel.startImAuthorization() |
同样先做并发保护检查,忙碌状态直接忽略。通过后状态切到 LaunchingImAuthorization。
第 2 步:prepareImAuthorization —— 构造授权 URL
① 生成防重放的 state
1 | val state = UUID.randomUUID().toString().replace("-", "") |
state 的作用是防止 CSRF 攻击——确保回跳回来的数据确实是这次授权发出去的,不是别人伪造的。
② state 双重持久化
1 | pendingImAuthorizationState = state // 内存(进程存活时用) |
为什么要存磁盘?因为跳到 IM App 后,Android 系统可能把 MyApp 进程回收(内存不足)。用户在 IM App 里完成授权、回跳时进程重新启动,内存里的 state 已经没了,必须从磁盘读回来校验。
③ 构造 imapp://corp-im/auth?… URL
ImAuthorizationUrlCodec.buildAuthorizationUrl() 把所有参数编码进 URL:
1 | imapp://corp-im/auth |
| 参数 | 含义 |
|---|---|
| callback | 授权完成后 IM App 把 token 带到这个 URL 回跳,scheme 是 myapp:// |
| state | 防重放标识,IM App 必须原样带回 |
| mode=local_token | 告诉 Hi:不走服务端接口,直接在本地把 token 放进回调 URL |
| cookie_name | 告诉 Hi 要回传哪个 Cookie 的 token |
| client_* | IM 授权页展示的 App 信息(图标、名称、说明文字) |
第 3 步:跳转 IM App
1 | _ssoEffect.send(SsoEffect.LaunchExternalUrl(url)) |
UI 层收到后:
1 | is SsoEffect.LaunchExternalUrl -> { |
imapp:// 是 IM App 注册的 URL scheme,Android 系统找到 IM App 并把它拉起来。此时 MyApp 退到后台,不需要等待,没有挂起的协程,没有 ServerSocket。
第 4 步:在 IM App 里发生了什么
这部分完全在 IM App 内部,MyApp 代码看不到,但从参数可以推断:
IM App 解析 imapp://corp-im/auth?… 里的参数
展示授权确认页(用 client_name、client_icon_url、client_subtitle、client_body 渲染)
用户点击「同意授权」
IM App 从本地取出当前登录用户的 access token(mode=local_token 决定了这一步在本地完成,不走网络)
IM App 把 token 和用户信息拼入 callback URL,用 startActivity 打开它:
1 | myapp://auth-callback |
第 5 步:MyApp 被 deep link 唤醒
myapp:// 是 MyApp 注册的 URL scheme。Android 系统收到这个 Intent,把 MyApp 拉到前台,把整个 URL 传给 MainActivity。
App 层拿到 callbackUrl 后调用:
1 | authViewModel.handleImAuthorizationCallback(callbackUrl) |
状态切到 HandlingImCallback。
第 6 步:parseCallback —— 校验 + 解析
ImAuthorizationUrlCodec.parseCallback() 做了一系列防御性检查,任何一步失败就返回 Failure:
1 | ① scheme 必须是 myapp,host 必须是 auth-callback |
token 字段的多键名兼容:IM App 不同版本回传的字段名可能不同,代码做了多键名兼容:
1 | private val tokenKeys = listOf("userserviceAccessToken", "access_token", "accessToken", "ssoAccessToken") |
URL 参数位置的双重解析:token 既可能在 query string(?key=value),也可能在 fragment(#?key=value),代码两个位置都解析,query 优先级高于 fragment:
1 | private fun parseParams(uri: URI): Map<String, String> { |
第 7 步:构造 Cookie(IM 登录专有)
与 SSO 登录最大的区别在这里:SSO 的 Cookie 由 UserService 服务端下发,IM 登录的 Cookie 由客户端本地构造。
1 | private fun buildImAuthorizationCookies(result: ImAuthorizationCallbackResult.Success): List<String> = |
构造完之后,持久化 + 更新状态 + 拉 Gateway 凭据,和 SSO 登录完全一样。
IM 登录 vs SSO 登录核心差异
| 对比维度 | SSO 登录 | IM 登录 |
|---|---|---|
| 用户操作 | 在浏览器输账号密码 | 在 IM App 点「同意授权」 |
| 回调机制 | HTTP redirect → 本机 ServerSocket | deep link → MainActivity |
| token 来源 | UserService 服务端 Set-Cookie 下发 | IM App 本地填入回调 URL |
| 进程存活要求 | 必须一直活着(WakeLock 保活) | 可被杀死,state 持久化到磁盘 |
| 超时机制 | 5 分钟 ServerSocket 超时 | 无超时,依赖 Hi 响应 |
| 防重放 | 靠本机端口唯一性 | UUID state + 磁盘持久化双重校验 |
八、URL Scheme 注册与系统路由机制
注册方式:AndroidManifest.xml 中的 Intent Filter
App 安装时,Android 系统扫描 AndroidManifest.xml,把所有 <intent-filter> 登记进系统的包管理器数据库(PackageManager)。MyApp 在 MainActivity 上声明了两条 deep link:
1 | <!-- IM App-to-App 授权回调 --> |
两条都用 myapp://,靠 host 区分用途:
auth-callback→ IM 登录回调auth+/callback路径前缀 → SSO 相关回调
系统如何路由这个 URL?
IM App 完成授权后,执行的代码等价于:
1 | // IM App 内部 |
Android 系统收到后按以下流程处理:
1 | 1. 从 Intent 里提取:action=VIEW,scheme=myapp,host=auth-callback |
launchMode="singleTop" 的关键作用
1 | <activity |
singleTop 的意思:如果返回栈顶已经有一个 MainActivity 实例,不新建,直接复用,通过 onNewIntent() 把新 Intent 传进来。
如果用默认的 standard 模式,每次 deep link 回来都会创建新的 MainActivity 实例,用户返回时会出现异常的页面栈。
<queries> 声明是干什么的?
1 | <queries> |
这是反向的:MyApp 要跳出去打开 IM App(imapp://)。Android 11+ 引入了包可见性限制——App 默认看不到其他 App 的存在。声明 <queries> 之后,MyApp 才能:
- 通过
packageManager.resolveActivity()检查 IM App 是否已安装 - 用
startActivity(Intent(ACTION_VIEW, Uri.parse("imapp://...")))成功跳转
不加这个声明,startActivity 会直接抛 ActivityNotFoundException,触发代码里 IM App 未安装的兜底分支。
两个 App 的双向关系
1 | MyApp IM App |
系统扮演的角色是路由器:谁注册了能处理这个 URL scheme 的 Activity,Intent 就发给谁。两个 App 通过 scheme 互相「拨号」,系统负责「接线」。