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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
用户              App(UI)          AuthViewModel     AuthRepository      系统浏览器          SSO服务            UserService
| | | | | | |
| 点击"SSO登录" | | | | | |
|---------------->| | | | | |
| | startSsoLogin() | | | | |
| |------------------>| | | | |
| | | prepareSsoLogin()| | | |
| | |----------------->| | | |
| | | | 开ServerSocket | | |
| | | | 构造SSO URL | | |
| | | | | | |
| | | SsoEffect. | | | |
| | | LaunchUrl(url) | | | |
| |<------------------| | | | |
| | startActivity | | | | |
| |------------------------------------------------->| | |
| | | | | | |
| | | awaitSsoCallback()| | | |
| | |----------------->| | | |
| | | | accept()阻塞等待 | | |
| | | | | | |
| | | | | 打开SSO登录页 | |
| 输入账号密码 | | | |----------------->| |
|------------------------------------------------------------------------------------------------>| |
| | | | | | 验证密码 |
| | | | | | 颁发ticket |
| | | | | redirect到 | |
| | | | | 127.0.0.1/callback?ticket=xxx |
| | | |<-----------------| | |
| | | | 收到HTTP请求 | | |
| | | | 提取ticket | | |
| | | | 返回成功HTML | | |
| | | |----------------->| | |
| 看到"登录成功"页| | | | | |
|<--------------------------------------------------------| | | |
| | | | | | |
| | | | exchangeTicket() | | |
| | | |---------------------------------------------------->|
| | | | | | 验证ticket |
| | | | | | 返回Cookie+用户信息
| | | |<----------------------------------------------------|
| | | | 存加密本地存储 | | |
| | 状态→Idle | | | | |
| |<------------------| | | | |
| 进入主界面 | | | | | |
|<-----------------| | | | | |

三、逐步拆解每个环节

第 1 步:用户点击按钮

LoginScreen.kt 中的 SSO 登录按钮绑定了 onSsoLoginClick 回调,点击后调用:

1
authViewModel.startSsoLogin(subsystemAlias = "userservice")

subsystemAlias 固定为 “userservice”,用于告知 SSO 服务和 UserService 这次登录属于哪个子系统。


第 2 步:并发保护检查

startSsoLogin 第一件事就是防止重复触发:

1
2
3
4
5
6
7
fun startSsoLogin(subsystemAlias: String) {
if (ssoLoginJob?.isActive == true || _ssoLoginState.value.isBusy()) {
return // ← 直接忽略,什么都不做
}
_ssoLoginState.value = SsoLoginState.LaunchingLogin
ssoLoginJob = viewModelScope.launch { runSsoLogin(subsystemAlias) }
}

“忙碌”的定义是:当前状态是 LaunchingLogin、HandlingCallback、LaunchingImAuthorization、HandlingImCallback 其中任意一种。也就是说,从点击按钮到登录完成的整个过程中,再次点击都会被静默忽略。


第 3 步:准备阶段——在本机开一个 HTTP 服务器

这是 SSO 流程最特殊的地方,也是和 IM 登录最大的区别。

authRepository.prepareSsoLogin(“userservice”) 做了这几件事:

3a. 关闭旧服务器(防止上次未正常关闭留下的残留)

1
callbackServer?.close()

3b. 创建新的 AuthCallbackServer

1
2
val server = AuthCallbackServer()
callbackServer = server

AuthCallbackServer 的构造函数:

1
2
3
4
5
6
7
8
9
10
11
private val serverSocket = ServerSocket(
0, // ← 端口 0 = 让 OS 随机分配一个空闲端口
1, // ← backlog=1,同时只接受 1 个连接
InetAddress.getByName("127.0.0.1") // ← 只监听本机回环地址,外部无法访问
)
val port: Int = serverSocket.localPort // 拿到 OS 分配的实际端口,比如 54321
val callbackUrl: String = "http://127.0.0.1:$port/callback"

init {
serverSocket.soTimeout = 5 * 60 * 1000 // 5 分钟超时
}

为什么要在本机开服务器?

SSO 的安全机制要求 ticket 必须被”redirect”回一个预先登记的地址(service 参数)。桌面端可以用 localhost,移动端也用同样的思路——在本机开一个临时 HTTP 服务器监听回调。

3c. 构造 SSO 登录 URL

1
2
3
4
fun buildLoginUrl(callbackUrl: String, subsystemAlias: String): String {
val encodedService = URLEncoder.encode(callbackUrl, "UTF-8")
return "${config.ssoHost}?service=$encodedService&subsystem=$subsystemAlias"
}

最终 URL 长这样(生产环境):

1
2
3
https://sso.example.com
?service=http%3A%2F%2F127.0.0.1%3A54321%2Fcallback
&subsystem=userservice

这个 URL 告诉 SSO 服务:用户登录成功后,把 ticket 带到 http://127.0.0.1:54321/callback 这个地址。


第 4 步:打开系统浏览器

prepareSsoLogin 返回 URL 后,ViewModel 通过 Channel 发出副作用:

1
2
_ssoEffect.send(SsoEffect.LaunchUrl(url))
_ssoLoginState.value = SsoLoginState.HandlingCallback // 状态更新

LoginScreen.kt 中的 LaunchedEffect 收到后:

1
2
3
is SsoEffect.LaunchUrl -> {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(effect.url)))
}

此刻 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 服务验证密码成功后,做两件事:

  1. 生成一个一次性 ticket(例如 ST-12345-abcdef-sso)

  2. 把浏览器 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
2
3
4
5
6
7
8
9
10
11
12
13
override suspend fun awaitSsoCallback(): Result<Unit> {
acquireWakeLock() // ← 先拿 WakeLock,防止 CPU 休眠
return try {
runCatching {
val server = callbackServer ?: error("No SSO callback server running")
val result = server.awaitCallback() // ← 阻塞在这里等浏览器回来
callbackServer = null
exchangeTicket(result.ticket, result.callbackUrl).getOrThrow()
}
} finally {
releaseWakeLock()
}
}

WakeLock 的作用:Android 系统可能在屏幕关闭时让 CPU 进入休眠,导致 ServerSocket.accept() 永远不会被唤醒。PARTIAL_WAKE_LOCK 让 CPU 保持运行,超时设置 6 分钟(略长于服务器的 5 分钟超时)。

awaitCallback() 内部的 HTTP 解析过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 读取请求行
val requestLine = reader.readLine()
// 内容类似: "GET /callback?ticket=ST-12345-abcdef-sso HTTP/1.1"

// 2. 消耗所有 header 行(必须读完,否则浏览器会一直等待)
while (true) {
val line = reader.readLine() ?: break
if (line.isEmpty()) break // 空行 = headers 结束
}

// 3. 解析路径和 ticket 参数
val ticket = parseQueryParam(queryString, "ticket")
// ticket = "ST-12345-abcdef-sso"

// 4. 向浏览器返回成功 HTML
sendResponse(writer, SUCCESS_HTML)
// HTML 里有 <meta http-equiv="refresh" content="2;url=myapp://auth/callback">
// 用户会看到"登录成功,正在返回 myapp..."

// 5. 返回结果
CallbackResult(ticket = ticket, callbackUrl = "http://127.0.0.1:54321/callback")

💡深入: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
2
3
4
5
6
7
8
POST https://userservice.example.com/auth/internal_login
Content-Type: application/json

{
"ticket": "ST-12345-abcdef-sso",
"validateService": "http://127.0.0.1:54321/callback",
"subsystemAlias": "userservice"
}

UserService 收到请求后:

  1. 拿着 ticket 去 SSO 服务验证(这是服务端对服务端的校验,客户端看不到)

  2. 验证 validateService 与当初颁发 ticket 时记录的 service 一致

  3. 查出对应的用户信息

  4. HTTP 响应头里下发 Set-Cookie,在 响应体里返回用户数据

响应示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
HTTP/1.1 200 OK
Set-Cookie: app-access-token-prod=eyJhbGciOi...; Path=/; Domain=.example.com; Secure; HttpOnly
Content-Type: application/json

{
"data": {
"name": "张三",
"email": "zhangsan@example.com",
"userNameAlias": "zhangsan",
"userId": "xxx",
"access-token": "eyJhbGciOi..."
}
}

兼容性处理:部分 UserService 环境只在 JSON 里返回 access-token,不下发 Set-Cookie。CookieCodec.withAccessTokenFallback 会把 JSON 里的值补合成一条标准格式的 Set-Cookie:

1
2
// 如果 Set-Cookie 里没有 access token,就自己造一条
"app-access-token-prod=eyJhbGciOi...; Path=/; Domain=.example.com; Secure; HttpOnly"

用户信息解析:兼容两种响应格式

  • internal_login:data.name、data.email 直接在 data 下

  • user_info(会话恢复时用):data.internalUser.name、data.internalUser.email 在嵌套层


第 8 步:持久化登录态

凭据换取成功后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 把 Cookie 列表和用户信息存入 EncryptedSharedPreferences
saveSsoSession(cookies, user)
// 用 AES256-GCM 加密,key 用 Android Keystore 保护

// 2. 更新内存状态
_authSnapshot.value = AuthSnapshot(
stage = Stage.SSO_AUTHENTICATED,
ssoSession = SsoSession(cookies, user, updatedAt = System.currentTimeMillis()),
gatewayCredential = null,
)

// 3. 尝试拉取 Gateway 凭据(失败不影响登录成功)
refreshGatewayCredential()
// POST /instance/get → 获取 Gateway URL 和 token
// 成功后 stage 升为 GATEWAY_READY

存储的 key 一览:

1
2
3
4
5
"sso_cookies_json"   → JSON 序列化的 Cookie 字符串列表
"user_snapshot_json" → JSON 序列化的用户信息
"auth_env" → 当前环境(PROD/BETA/SIT)
"gateway_url" → Gateway 地址
"gateway_token" → Gateway token

第 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
2
3
4
5
6
7
8
9
fun cancelSsoLoginIfWaiting() {
if (!_ssoLoginState.value.isBusy()) return
try {
authRepository.cancelSsoLogin() // 关闭 ServerSocket
} finally {
ssoLoginJob?.cancel() // 取消协程
_ssoLoginState.value = SsoLoginState.Idle
}
}

场景 C:ticket 已过期或被用过(一次性)

UserService 返回非 2xx,error(“internal_login failed: ${response.code}”) 被 runCatching 捕获,状态切为 Error。

场景D:协程被取消(App 进后台被系统杀死等)

CancellationException 被单独捕获,状态回 Idle 并重新 throw,确保协程取消语义正确:

1
2
3
4
} catch (e: CancellationException) {
_ssoLoginState.value = SsoLoginState.Idle
throw e // ← 必须重新抛出,否则协程无法正常取消
}

五、下次启动如何恢复登录态(restoreSession)

App 不是每次都要走完整的 SSO 流程。下次启动时:

1
2
3
4
5
1. 从 EncryptedSharedPreferences 读出 cookies + user
2. POST /auth/user_info(带上已存的 Cookie)
→ UserService 验证 Cookie 是否仍有效
3. 有效 → 直接恢复 SSO_AUTHENTICATED,跳过登录页
4. 无效 → clearAuth(),显示登录页,要求重新 SSO

旧版本兼容:如果本地只有旧二维码凭据(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
2
val state = UUID.randomUUID().toString().replace("-", "")
// 例如:a1b2c3d4e5f6...(32 位随机字符串)

state 的作用是防止 CSRF 攻击——确保回跳回来的数据确实是这次授权发出去的,不是别人伪造的。

② state 双重持久化

1
2
pendingImAuthorizationState = state                              // 内存(进程存活时用)
prefs?.edit()?.putString(KEY_PENDING_IM_STATE, state)?.apply() // 加密磁盘(进程重启后也能校验)

为什么要存磁盘?因为跳到 IM App 后,Android 系统可能把 MyApp 进程回收(内存不足)。用户在 IM App 里完成授权、回跳时进程重新启动,内存里的 state 已经没了,必须从磁盘读回来校验。

③ 构造 imapp://corp-im/auth?… URL

ImAuthorizationUrlCodec.buildAuthorizationUrl() 把所有参数编码进 URL:

1
2
3
4
5
6
7
8
9
10
11
12
imapp://corp-im/auth
?callback=myapp%3A%2F%2Fauth-callback%3Fstate%3D...%26provider%3Dhi
&state=a1b2c3d4e5f6...
&subsystem=myapp
&source=android
&mode=local_token
&cookie_name=app-access-token-prod
&client_name=MyApp
&client_icon_url=https://...(myapp 的图标)
&client_subtitle=使用当前 IM 账号继续访问 MyApp
&client_body=MyApp 仅会使用本次授权完成企业登录和工作区连接。
&consent_version=myapp_mobile_v1
参数 含义
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
2
3
4
5
6
7
is SsoEffect.LaunchExternalUrl -> {
try {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(effect.url)))
} catch (e: ActivityNotFoundException) {
authViewModel.handleImAuthorizationLaunchFailed() // IM 未安装兜底
}
}

imapp:// 是 IM App 注册的 URL scheme,Android 系统找到 IM App 并把它拉起来。此时 MyApp 退到后台,不需要等待,没有挂起的协程,没有 ServerSocket

第 4 步:在 IM App 里发生了什么

这部分完全在 IM App 内部,MyApp 代码看不到,但从参数可以推断:

  1. IM App 解析 imapp://corp-im/auth?… 里的参数

  2. 展示授权确认页(用 client_name、client_icon_url、client_subtitle、client_body 渲染)

  3. 用户点击「同意授权」

  4. IM App 从本地取出当前登录用户的 access token(mode=local_token 决定了这一步在本地完成,不走网络)

  5. IM App 把 token 和用户信息拼入 callback URL,用 startActivity 打开它:

1
2
3
4
5
6
7
8
9
10
myapp://auth-callback
?state=a1b2c3d4e5f6... ← 原样带回,用于校验
&provider=im
&email=zhangsan@example.com
&userserviceAccessToken=eyJhbGci... ← access token
&name=张三
&userNameAlias=zhangsan
&avatar=https://...
&userId=xxx
&sessionId=yyy

myapp:// 是 MyApp 注册的 URL scheme。Android 系统收到这个 Intent,把 MyApp 拉到前台,把整个 URL 传给 MainActivity。

App 层拿到 callbackUrl 后调用:

1
authViewModel.handleImAuthorizationCallback(callbackUrl)

状态切到 HandlingImCallback。

第 6 步:parseCallback —— 校验 + 解析

ImAuthorizationUrlCodec.parseCallback() 做了一系列防御性检查,任何一步失败就返回 Failure

1
2
3
4
5
6
7
8
9
10
11
12
13
① scheme 必须是 myapp,host 必须是 auth-callback
↓ 通过
② pendingState 不能为空(内存 or 磁盘读回)
↓ 通过
③ provider 必须是 "im"
↓ 通过
④ 检查 error / error_description 参数(IM 授权失败时会带这两个)
↓ 无错误
⑤ state 必须与 pendingState 完全一致(防 CSRF 核心)
↓ 一致
⑥ email 和 access token 不能为空
↓ 都有值
⑦ 返回 Success,携带所有字段

token 字段的多键名兼容:IM App 不同版本回传的字段名可能不同,代码做了多键名兼容:

1
2
private val tokenKeys = listOf("userserviceAccessToken", "access_token", "accessToken", "ssoAccessToken")
private val emailKeys = listOf("email", "userEmail", "mail")

URL 参数位置的双重解析:token 既可能在 query string(?key=value),也可能在 fragment(#?key=value),代码两个位置都解析,query 优先级高于 fragment:

1
2
3
4
5
private fun parseParams(uri: URI): Map<String, String> {
val queryParams = parseQuery(uri.rawQuery)
val fragmentParams = parseFragment(uri.rawFragment)
return fragmentParams + queryParams // query 覆盖 fragment
}

第 7 步:构造 Cookie(IM 登录专有)

与 SSO 登录最大的区别在这里:SSO 的 Cookie 由 UserService 服务端下发,IM 登录的 Cookie 由客户端本地构造

1
2
3
4
5
6
7
private fun buildImAuthorizationCookies(result: ImAuthorizationCallbackResult.Success): List<String> =
buildList {
add("${result.cookieName}=${result.accessToken}; Path=/; Domain=.example.com; Secure; HttpOnly")
if (result.sessionId.isNotBlank()) {
add("session_id=${result.sessionId}; Path=/; Domain=.example.com; Secure; HttpOnly")
}
}

构造完之后,持久化 + 更新状态 + 拉 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- IM App-to-App 授权回调 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="auth-callback" />
</intent-filter>

<!-- SSO 回调(冗余保留)-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="auth" android:pathPrefix="/callback" />
</intent-filter>

两条都用 myapp://,靠 host 区分用途:

  • auth-callback → IM 登录回调
  • auth + /callback 路径前缀 → SSO 相关回调

系统如何路由这个 URL?

IM App 完成授权后,执行的代码等价于:

1
2
3
// IM App 内部
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("myapp://auth-callback?state=xxx&..."))
startActivity(intent)

Android 系统收到后按以下流程处理:

1
2
3
4
5
1. 从 Intent 里提取:action=VIEW,scheme=myapp,host=auth-callback
2. 查 PackageManager:谁注册了能处理这个 Intent 的 Activity?
3. 找到 MyApp 的 MainActivity
4. 进程还活着 → 拉到前台,调 onNewIntent(intent)
进程被杀了 → 重启进程,调 onCreate(intent)

launchMode="singleTop" 的关键作用

1
2
3
4
<activity
android:name=".MainActivity"
android:launchMode="singleTop"这里
...>

singleTop 的意思:如果返回栈顶已经有一个 MainActivity 实例,不新建,直接复用,通过 onNewIntent() 把新 Intent 传进来。

如果用默认的 standard 模式,每次 deep link 回来都会创建新的 MainActivity 实例,用户返回时会出现异常的页面栈。

<queries> 声明是干什么的?

1
2
3
4
5
6
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="imapp" />
</intent>
</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
2
3
4
5
6
7
8
9
10
11
12
13
MyApp                                     IM App
│ │
│ 注册 intent-filter: │ 注册 intent-filter:
│ myapp://auth-callback │ imapp://corp-im/auth
│ │
│ <queries> 声明能看见 imapp:// │
│ │
│──── startActivity(imapp://...) ───────>│ 被系统拉起,展示授权页
│ │
│<─── startActivity(myapp://...) ────────│ 授权完成,回跳
│ │
│ onNewIntent() 收到 deep link │
│ parseCallback() 校验 state │

系统扮演的角色是路由器:谁注册了能处理这个 URL scheme 的 Activity,Intent 就发给谁。两个 App 通过 scheme 互相「拨号」,系统负责「接线」。