名称解释

名称说明
Sid 或者 ServiceId接入小米账号组服务的产品 ID,用来唯一标示各个接入方;申请方式见《服务端如何接入小米账号》
serviceTokenserviceToken 是用户在业务侧(与 sid 对应)登录态的凭证
authTokensso 登录过程中用于各个服务和 passport 服务器间认证的一种 token, auth 是各个 sid 私钥加密后的密文串
passToken用户在 passport 认证中心的登录态凭证,passToken 可以理解为 sid 是 passport 的 serviceToken;
callback url用户在 passport 登录成功之后,会重定向到业务的 callback url,并在 Callback url 的域名下发登录凭证 cookie(即 serviceToken)。passportsdk 中有 callback url 的 controller,path 为 “/sts”。 假设业务的域名是 biz.mi.com,回调 URL 建议是 https://biz.mi.com/sts
XiaomiAccountMIUI 中小米账号 app(系统签名 app),注册相应 Authenticator,接入 AccountManager,提供小米账号对应的系统服务
OneAccountSdk小米账号团队对外提供的账号 sdk,通过该 sdk 可接入系统账号或本地账号服务;当系统中包含 XiaomiAccount 时可直接使用 XiaomiAccount 提供的系统账号服务,否则只能使用 sdk 中自带的本地账号服务

Android 的 AccountManager 机制,见 https://developer.android.com/reference/android/accounts/AccountManager.html 参考:《小米账号 SSO 登录涉及到的 Token 调研文档》

车机上需要实现能力

  1. 系统账号能力:
    1. 实现类似 XiaomiAccount 的角色,在车机上实现相应 Authenticator,将小米账号接入系统 AccountManager 中,对车机上其他 app 或服务提供统一的小米账号相应能力,目前包含:获取账号信息、扫码登录、获取 serviceToken 等能力;
    2. 仓库:platform/packages/apps/Car/MiCarAccount,分支:dev
      1. http://gerrit.auto.mioffice.cn/c/platform/packages/apps/Car/MiCarAccount/+/1089
  2. 对外提供接入:
    1. 对于系统签名 app:使用 AccountManager 提供的机制即可
    2. 非系统签名 app:(暂未支持)需要额外提供接入 sdk,依赖后续需求情况

系统签名 App 接入

product/staging 环境切换

  1. 以下功能用法 Demo 详见:MiCarAccount 中 sample module
  2. 目前登录环境已切回 product 环境(7.20 之后的 dailyBuild rom 生效),如果需要 staging 环境下的 serviceToken 做开发调试,则需要手动切换到 staging 环境
    1. 切换 product/staging 环境方法:
      1. adb root & adb remount
      2. 切换到 staging 环境:adb shell touch /data/system/xiaomi_account_preview & adb shell pkill -9 account
      3. 切换回 product 环境:adb shell rm /data/system/xiaomi_account_preview & adb shell pkill -9 account
    2. 2023.1.10 以后的 rom 包,切环境方法见《个人中心切换环境方法》
    3. staging 环境需要使用 staging 账号,环境如下:
      1. 登录/注册页面:http://account.preview.n.xiaomi.net/
      2. 短信验证码查询:https://support-account-staging.pt.xiaomi.com/guest/ticket/query

配置权限

Android 中对于 AccountManager 行为有相应的权限控制

车机上对应的 Authenticator 就是 MiCarAccount app,所以和该 app 有相同签名的其他系统 app 不需要额外配置其他权限,即可通过 AccountManager 的接口获取小米账号的信息。

获取 Account

  • 小米账号 Type 是 com.xiaomi
  • 小米账号的 mid = Account.name
fun getXiaomiAccount(context: Context?): Account? {
    if (context == null) {
        return null
    }
    var account: Account? = null
    val am = AccountManager.get(context.applicationContext)
    val accounts = am.getAccountsByType("com.xiaomi")
    // u0 下调用需要采用 asUser 的方式,这儿不需要 user 空间 unlock
    // val accounts = am.getAccountsByTypeAsUser("com.xiaomi", UserHandle.of(curUserId));
    // 获取前台 user id
    // val userId = ActivityManager.getCurrentUser()
 
    // 正常
    if (accounts.size > 0) {
        account = accounts[0]
    }
    return account
}

添加 Account / 扫码登录

注意 如需调用账号登录弹窗,请移步 登录弹窗/个人中心首页 调用方法

  • 当获取 Account 为 null 时,如果需要,可以调起登录页面,此时 AccountManager 会调用 MiCarAccount 中实现的相应的登录 & 添加逻辑;
    • accountType 传入 com.xiaomi,authTokenType 传入相应的 sid,activity 不可为 null,否则无法跳转扫码登录页面。callback 中可以获取相应处理结果,如果 callback 为空的话,也可以通过监听账号广播来继续后续操作。
  • 由于车机上存在代客/访客等场景,具体系统应用该如何处理登录方案详见 2.5 系统应用登录方案建议
public AccountManagerFuture<Bundle> addAccount(final String accountType,
        final String authTokenType, final String[] requiredFeatures,
        final Bundle addAccountOptions,
        final Activity activity, AccountManagerCallback<Bundle> callback, Handler handler)
 
// example
AccountManager.get(context).addAccount("com.xiaomi", "your sid", null, options, activity, { future ->
    var success = false
    var code = -1
    try {
        success = future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)
        code = future.result.getInt(AccountManager.KEY_ERROR_CODE)
    } catch (e: Exception) {
        Log.e(TAG, "signIn", e)
    }
}, null)

获取 serviceToken

获取 token 一般都是在添加账号或者 token 过期后;同时所有需要 serviceToken 访问的服务都需要考虑 token 过期的情况时效是生成时服务端配置的,通常为 24 小时),一般步骤如下:

  1. 正常使用如下方法获取 token,访问服务
  2. 如果对应服务 API 返回 401,表示 token 已过期的话,此时需要:
    1. 先清除缓存的 token(方法参照 清除 serviceToken 缓存),
    2. 通过 getAuthToken 获取新的 serviceToken,重新访问服务;
public AccountManagerFuture<Bundle> getAuthToken(
        final Account account, final String authTokenType, final Bundle options,
        final Activity activity, AccountManagerCallback<Bundle> callback, Handler handler)
 
public AccountManagerFuture<Bundle> getAuthToken(
        final Account account, final String authTokenType, final Bundle options,
        final boolean notifyAuthFailure,
        AccountManagerCallback<Bundle> callback, Handler handler)

第一个方法 activity 不为 null 的情况下在**本地账号过期(密码被修改等)**时会直接弹出界面让用户重新扫码验证;而第二个方法则不会,只会在 callback 中返回相应重新登录页面的 intent,后续逻辑需要用户自定义完成;一般如果 app 在前台的情况下建议使用第一个方法;

// sample 方法一
val account = getXiaomiAccount(context)
AccountManager.get(context).getAuthToken(account, "your sid", null, activity, { future ->
    var success = false
    var token: String? = null
    try {
        token = future.result.getString(AccountManager.KEY_AUTHTOKEN)
        success = token != null
    } catch (e: Exception) {
        Log.e(TAG, "getAuthToken", e)
    }
}, null)
 
// sample 方法二
AccountManager.get(context).getAuthToken(account, sid, null, true, { future ->
    var success = false
    var token: String? = null
    try {
        val intent = future.result.getParcelable<Intent>(AccountManager.KEY_INTENT)
        intent?.let {
            token = "ERROR_PASSTOKEN_INVALID"
        } ?: run {
            token = future.result.getString(AccountManager.KEY_AUTHTOKEN)
            success = token != null
        }
    } catch (e: Exception) {
        Log.e(TAG, "getAuthToken", e)
    }
    callback(success, token)
}, null)

获取到的 AuthToken 是由 ServiceToken 和 SSecurity 拼接而成,用逗号分隔;可使用下面工具类解析获取;

public final class ExtendedAuthToken {
 
    private static final String SP = ",";
 
    public final String authToken;
 
    public final String security;
 
    private ExtendedAuthToken(String authToken, String security) {
        this.authToken = authToken;
        this.security = security;
    }
 
    public static ExtendedAuthToken build(String authToken,
            String security) {
        return new ExtendedAuthToken(authToken, security);
    }
 
    public static ExtendedAuthToken parse(String plain) {
        if (TextUtils.isEmpty(plain)) {
            return null;
        }
        String[] parts = plain.split(SP);
        if (parts.length != 2 || TextUtils.isEmpty(parts[0]) || TextUtils.isEmpty(parts[1])) {
            return null;
        }
        return new ExtendedAuthToken(parts[0], parts[1]);
    }
 
    public String toPlain() {
        return authToken + SP + security;
    }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
 
        ExtendedAuthToken that = (ExtendedAuthToken) o;
 
        if (authToken != null ? !authToken.equals(that.authToken)
                : that.authToken != null) {
            return false;
        }
        if (security != null ? !security.equals(that.security)
                : that.security != null) {
            return false;
        }
 
        return true;
    }
 
    @Override
    public int hashCode() {
        int result = authToken != null ? authToken.hashCode() : 0;
        result = 31 * result + (security != null ? security.hashCode() : 0);
        return result;
    }
}

在 U0 用户空间的进程如果想获取当前前台用户的 mid、token 等信息,可以参考 MiCarPropertySwitcher 里的这种用法,在 u0 进程里启动一个 android:singleUser="false" 的 provider,那么这个 provider 可以运行在当前前台用户的空间中,通过 call provider 时传入前台 user id 来调用。

https://gerrit.auto.mioffice.cn/plugins/gitiles/cockpit/core/platform/android/services/MiCarPropertySwitcher/+/refs/heads/master/recognition/src/main/kotlin/com/micar/property/switcher/recognition/user/crossusers/AccountContentProvider.kt

清除 serviceToken 缓存

正常情况下系统中会缓存上次获取的 serviceToken,如果过期后,访问服务会返回 401,此时需要清除系统中缓存 token,重新获取新的 ServiceToken;

📌 注意 该方法需要传入的 cachedAuthToken 需要是前面 getAuthToken 返回的完整内容,而不是经过 ExtendedAuthToken 解析得到的不带 SSecurity 的 ServiceToken

📌 日志确认 业务端接入时可以通过过滤 “SystemAccountAuthentica” 关键字,查看账号请求 AuthToken 的相关日志来确认是否 invalidateAuthToken 生效,并且重新通过账号获取新的 authToken

  • 开始请求(有对应 type、packageName 信息) 06-04 08:14:44.494124 10075 10104 I SystemAccountAuthentica: getting AuthToken, type: iccc_car_api, notifyOnAuthFailure: true, package name: com.mi.car.account
  • 请求成功 06-04 08:14:45.041921 10075 10104 I SystemAccountAuthentica: type: iccc_car_api, package name: com.mi.car.account, getAuthToken succeed

建议各个 App 自行缓存上次使用的 authToken,以便 invalidate 时使用,同时如果缓存丢失,则可以使用 getAuthToken 方法重新获取一次(但是至少会多一次跨进程 Binder 调用)

private fun invalidateAuthToken(context: Context) {
    try {
        val am = AccountManager.get(context)
        val account = getXiaomiAccount(context)
        var cachedAuthToken = "上次使用的token"
        if (TextUtils.isEmpty(cachedAuthToken)) {
            cachedAuthToken = am.getAuthToken(account, "your sid", null, true, null, null)
                                .result
                                .getString(AccountManager.KEY_AUTHTOKEN)
        }
        am.invalidateAuthToken("com.xiaomi", cachedAuthToken)
    } catch (e: Exception) {
        Log.e(TAG, "invalidateAuthToken", e)
    }
}

账号密码验证

为业务提供接口验证当前账号的密码来保证当前用户是该账号的拥有者;车机上目前只提供扫码登录,所以这里也是通过调起扫码登录的 UI 页面进行验证;

val account = getXiaomiAccount(context)
AccountManager.confirmCredentials(account, null, activity, { future ->
    var success = false
    var code = -1
    try {
        success = future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)
        code = future.result.getInt(AccountManager.KEY_ERROR_CODE)
    } catch (e: Exception) {
        Log.e(TAG, "confirmCredentials", e)
    }
}, null)

账号身份验证

使用场景:为业务提供扫码验证页面,业务方拉起验证身份的二维码,待有权限的用户使用手机车主 App 扫码确认后,车端返回给业务方结果,执行后续逻辑。

目前本业务只适用于恢复出厂设置密码手套箱验证两个场景,其他业务调用需要以需求形式给到帐号 pm 后对接(需求细节:《【产品需求文档】半自动密码手套箱》3.2 节,密码解锁部分)。

需要传入的参数:

💡 参数说明

  • (必选)key = confirm_type, value = owner_confirm,申明需要调用的是车主验证的二维码页面
  • (必选)key = confirm_scene, value = 需要联系服务端确定;主要用于手机端扫码后展示文案的配置,不能为空,这里 factoryReset 只是一个示例,最终需要联系服务端确定
  • (可选)key = page_title, value = 自定义,页面 title

调用方式,包括参数传入的方式见下面代码:

// 方式 1, 目前已弃用
// 弃用原因:该方式通过原生的 Account 相关 Api 拉起验证页面,该 Api 强制需要传入登录用户信息,
// 而本功能可以适用于当前车机未登录帐号的场景
val account = getXiaomiAccount(context)
if (account == null) {
    return
}
val am = AccountManager.get(activity.applicationContext)
val options = Bundle()
// 1. 申明需要调用的是车主验证的二维码页面, key = confirm_type, value = owner_confirm
options.putString("confirm_type", "owner_confirm")
// 2. 页面 title 可以自定义, key = "page_title", value = 自定义
options.putString("page_title", "车主验证")
// 3. 调用验证的场景, key = "confirm_scene", value = 需要联系服务端确定
options.putString("confirm_scene", "factoryReset")
am.confirmCredentials(account, options, activity, { future ->
    var success = false
    try {
        success = future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)
    } catch (e: Exception) {
        Log.e(TAG, "confirmOwner", e)
    }
}, null)
 
 
// 方式 2 推荐
// 在 fragment 页面拉起对应 Activity
private lateinit var mConfirmLauncher: ActivityResultLauncher<Intent>
mConfirmLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult(),
    this::onConfirmResult)
private fun onConfirmResult(result: ActivityResult) {
    if (result.resultCode == Activity.RESULT_OK) {
        showToast("验证成功")
    } else {
        showToast("验证失败")
    }
}
 
val options = Bundle()
options.putString("confirm_type", "owner_confirm")
options.putString("page_title", "车主验证")
options.putString("confirm_scene", "factoryReset")
val intent = Intent("com.mi.car.account.action.XIAOMI_ACCOUNT_CONFIRM_OWNER")
intent.setPackage("com.mi.car.account")
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.putExtras(options)
mConfirmLauncher.launch(intent)
 
// 方式 3
// 适合不带有前台页面的应用拉起验证

账号变化监听

  1. 原生 AccountManager:
    1. AccountMananger.addOnAccountsUpdatedListener
    2. AccountMananger.ACTION_ACCOUNT_REMOVED
  2. 自定义广播:
    1. SYSTEM_LOGIN_ACCOUNTS_PRE_CHANGED_ACTION:变化前
    2. SYSTEM_LOGIN_ACCOUNTS_POST_CHANGED_ACTION:变化后
    3. SYSTEM_ACCOUNT_USER_DATA_CHANGED_ACTION:add 账号后,会异步联网请求账号信息(用户名/手机号/头像等),获取成功后会发送该广播通知
    4. EXTRA_UPDATE_TYPE:变化类型,add/refresh

📌 权限要求 监听广播需要使用指定权限 <uses-permission android:name="mi.car.account.permission.ACCESS_BROADCAST" /> 具体方式可参考《【重要通知】个人中心广播权限接入通知及方案介绍》

// 和 MIUI 中 ExtraAccountManager 的常量值保持一致 begin
// system account changed broadcast action
private static final String SYSTEM_LOGIN_ACCOUNTS_PRE_CHANGED_ACTION = "android.accounts.LOGIN_ACCOUNTS_PRE_CHANGED";
 
private static final String SYSTEM_LOGIN_ACCOUNTS_POST_CHANGED_ACTION = "android.accounts.LOGIN_ACCOUNTS_POST_CHANGED";
 
private static final String SYSTEM_ACCOUNT_USER_DATA_CHANGED_ACTION = "com.mi.car.account.USER_DATA_CHANGED";
 
public static final String EXTRA_UPDATE_TYPE = "extra_update_type";
 
public static final String EXTRA_ACCOUNT = "extra_account";
 
public static final int TYPE_REMOVE = 1;
 
public static final int TYPE_ADD = 2;
 
public static final int TYPE_REFRESH = 3;

获取账号信息

收到上述 SYSTEM_ACCOUNT_USER_DATA_CHANGED_ACTION 广播后,表示联网请求信息完成;通过 AccountMananger.getUserData(account, key) 可以获取相关用户信息,目前支持的信息对应的 KEY 如下:

📌 兜底提示 账号基础信息(用户名/头像/手机号等)也需要联网获取的,如果获取失败的话,没有触发重试逻辑(切账号/重新进入个人中心等)的话,是有可能获取不到的,业务端如需使用下列数据需要考虑获取不到的兜底情况

// 依赖联网请求的数据,请求成功后会发出 SYSTEM_ACCOUNT_USER_DATA_CHANGED_ACTION 广播
const val ACCOUNT_USER_NAME = "acc_user_name" // 用户名
const val ACCOUNT_USER_EMAIL = "acc_user_email"
const val ACCOUNT_USER_PHONE = "acc_user_phone"
const val ACCOUNT_AVATAR_URL = "acc_avatar_url" // 头像地址
const val ACCOUNT_USER_GENDER = "acc_user_gender"
 
// cUserId 账号登录成功后即可获取
const val KEY_ENCRYPTED_USER_ID = "encrypted_user_id"
 
// 当前账号是否为车主, 字符串类型, "TRUE" 表示为车主
const val ACCOUNT_IS_CAR_OWNER = "acc_is_car_owner"
 
// 当前账号的第一次登录时间,值为: System.currentTimeMillis().toString()
const val ACCOUNT_FIRST_LOGIN_TIMESTAMP = "acc_first_login_timestamp"

登录弹窗/个人中心首页 调用方法

参考:《账号登录弹窗调用》

访客/代客模式判断

int isGuest = Settings.Secure.getInt(context.getContentResolver(), "judge_user_guest", 0);
// isGuest = 1 代表当前 User 是代客
// isGuest = 0 代表当前不是代客
int isAnonymous = Settings.Secure.getInt(context.getContentResolver(), "judge_user_anonymous", 0);
// isAnonymous = 1 代表当前用户是访客
// isAnonymous = 0 代表当前用户不是访客

域名白名单配置

重要 本节主要指导各业务在配置企业流量白名单域名时,也需要配置获取 token 时 sts 地址对应的域名。 如果不配置 sts 地址的域名流量白名单,在流量耗尽时,会导致对应业务请求 token 失败。

在车端,各业务通过上面的步骤可以接入到账号体系了,除了获取到当前账号的信息外,涉及到与云端通信的业务,会通过 获取 serviceToken 一节来获取访问自己业务云端需要的 token。

在获取 token 时,各自业务云端接口负责人,需要清楚自己业务是如何接入的小米账号,接入方的 sid 是什么?并且需要把这个 sid 同步给车端研发,才能通过 sid + Account manager 接口,请求账号应用获取到 token。

在车端获取 token 的流程类似这个图。

车端各个业务接入了账号后,对于接口访问要使用企业流量,而非用户流量的场景,除了把自身业务的接口域名加入企业流量白名单外,还需要把图中 sts 地址也加入到白名单里来,这个地址可以跟业务的云端老师要一下,因为服务端在对接小米账号时,是需要配置 sts 地址的。如果不把 sts 地址配置白名单,会出现用户流量耗尽后,获取业务 sid 对应的 token 时,由于 sts 地址访问不通,导致账号 token 无法获取,进而影响各业务的正常功能。

非系统签名 App 接入

获取账号信息

📌 限制 目前仅限米家应用使用!!

val targetUri = Uri.parse("content://mi.car.account.extend")
val bundle: Bundle? = context?.contentResolver?.call(targetUri, "getCurrentUserMid", null, null)
val mid = bundle?.getString("account_mid")
 
// 目前会做以下限制(通过签名验证)
// 1. 验证请求方的包名,必须米家 app 包名才会返回数据,否则为空
// 2. 签名校验,包名 + 签名验证都匹配才可以返回数据

📥 抓取自飞书 wiki 空间 7541192071952990209,obj_token: UeEUddIUWoTUfhxLUrpcpXoZn6g