现在越来越多的 App 开始支持本机号码一键登录的功能了,通过和运营商合作,获取到当前使用网络的手机卡号码,进行登录流程,能够减少用户的操作步骤,提高转化率。

众所周知,国内现在主流通讯运营商有三家,分别是中国移动、中国联通和中国电信。

这就意味着,对接三家运营商就有三套对接流程,要对接三次 SDK,但,正如所有的聚合服务所需,总会产生聚合这三家运营商一键登录服务的 SDK 供大家使用。

阿里云作为国内数字经济基础设施比较完善的代表,往往会作为许多企业的首选。我所在的企业,自然也是选择了它。

文前预警,本文需配合阿里云本机号码一键登录文档一同食用,建议先按照文档和官方提供的 Demo 先接一遍了解流程。

阿里云

接入阿里云本机号码一键登录服务首先要到阿里云后台申请参数,申请参数需要提供应用的包名和签名,然后阿里云会为我们生成一个密钥。

之前的文章有提到过,我目前从事游戏行业,国内游戏行乱象大家都知道,马甲包遍地都是,拥有不同包名的马甲包如果想要都接入本机号码一键登录功能的话,就得申请多套不同的参数,这大大增加了对接成本。

那么,我们能不能绕过阿里云对包名的检测,让多个马甲包都共用同一套参数呢?

来试试看。

我这里拿阿里云官方 Demo 提供的参数进行演示,实际项目按需修改。

public class AliParameter {

    // 阿里云官方 Demo 里的包名,替换为申请参数时填写的包名
    public static String AUTH_PACKAGE_NAME = "com.aliqin.mytel";

    // 阿里云官方 Demo 里的密钥,替换为申请的密钥,理论上密钥应当放在服务端
    public static String AUTH_KEY = "7KHffk2Cn1j17+QVA2zbJfdDteDSUDspB/s+FUoAhyXmQ/wueAQBcpMDOVLrp5lt5BDIGxDrCuTBZk7TcR4CxAQvHnJUPIaCI5dscbBFqHgHVI8Yoy0nYwsFo8Gyd2RZ6MbUAZr3lsnPQsA+UW1MZY9EP94x0TrXmwEJkU5xJgmOJfCSekYWHP5xNc0as/aWkTmNrjFyb5//93cAMwQllH0FFEFF+GEd7XMvm6ap/g4BD8676+z29MbePXPjoY6u3VrNTMkksQHW1EolxJkw+9Sa5pDsdOrQjXBz056J79PpNAFlTvPMZw==";
    
    // 三大运营商的协议地址
    public static String PHONE_AUTH_YIDONG_CONTRACT = "https://wap.cmpassport.com/resources/html/contract.html";
    public static String PHONE_AUTH_LIANTONG_CONTRACT = "https://ms.zzx9.cn/html/oauth/protocol2.html";
    public static String PHONE_AUTH_DIANXIN_CONTRACT = "https://e.189.cn/sdk/agreement/content.do?type=main&appKey=&hidetop=true";
}

记得签名也要配置,参考之前『「Android Studio」用 Gradle 实现自动签名』一文:

android {
    ...
    signingConfigs {        // 阿里云官方 Demo 里的签名,应用中应替换为申请参数时填写的签名
        config {
            keyAlias 'androiddebugkey'
            keyPassword 'android'
            storeFile file('sig-adaptation/debug/debug.keystore')
            storePassword 'android'
            v1SigningEnabled true
            v2SigningEnabled true
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.config
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
        debug {
            debuggable true
            signingConfig signingConfigs.config
        }
    }
}

首先需要了解阿里云本机号码一键登录 SDK 是如何检测包名的,很简单,跟我们获取包名的操作类似,通过调用 ContextgetPackageName() 来获取,再与服务端校验。

Context 如何获取,看初始化就可以,大多数 SDK 的初始化操作都是为了获取 Context 对象,而幸运的是,阿里云本机号码一键登录 SDK 并没有使用 App Startup 这些新技术,而是让对接方手动调用:

/**
 * 获取号码认证服务实例,此实例为单例,获取多次为同⼀对象
 * @param context Android上下⽂
 * @param tokenListener 需要实现的获取token回调
 * @return PhoneNumberAuthHelper
 */
public static PhoneNumberAuthHelper getInstance(Context context, TokenResultListener tokenListener)

这就简单许多了,假如我们能够自定义一个 Context 传给本机号码一键登录的 SDK,使得 SDK 在获取包名时,返回我们申请参数的包名而不是实际包名,就能够绕过它的检测机制了。

Context 是一个抽象类,而在 Android 中对 Context 的实现非常多,但往往继承越深,代码也就越复杂,根据开发经验,继承自 ContextApplication 对于获取这种全局的东西往往会比其他组件要稍微方便一些,所以可以自定义一个 Application 来实现我们的需求。

public class AliApplication extends Application {
    public AliApplication(Context realContext) {
        try {
            Method method = Application.class.getDeclaredMethod("attach", Context.class);
            method.setAccessible(true);
            method.invoke(this, realContext);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

因为这个 Application 我们只用来欺骗阿里云本机号码一键登录 SDK,所以不会在 AndroidManifest 中注册它,那么这个 Application 的一些初始化方法就不能得到执行。

所以需要采用反射获取,并使用 invoke() 执行实例对应的方法。

当我们把这个 Application 对象传给 SDK 后,包名和签名都会由这个 Application 来提供。

先来看签名,因为我们的签名不需要改变,所以只需要将应用本身的签名回传即可。

写一个获取应用签名的方法:

public static Signature[] getSignatures(PackageManager manager, String packageName) {
    try {
        PackageInfo packageInfo = manager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
        return packageInfo.signatures;
    } catch (PackageManager.NameNotFoundException e) {
        e.printStackTrace();
    }
    return null;
}

这时候你应该发现,获取应用签名需要提供一个 PackageManager 对象,阿里云本机号码一键登录 SDK 也是如此,那就得再向它提供一个自定义的 PackageManager 了。

public class AliPackageManager extends PackageManager {
    private final PackageManager packageManager;
    private final String realPackageName;
    private Signature[] sSignature;

    public AliPackageManager(PackageManager packageManager, String realPackageName) {
        this.packageManager = packageManager;
        this.realPackageName = realPackageName;
    }

    @Override
    public PackageInfo getPackageInfo(@NonNull String packageName, int flags) throws NameNotFoundException {
        PackageInfo packageInfo = packageManager.getPackageInfo(realPackageName, flags);
        packageInfo.signatures = getSignature();
        packageInfo.packageName = AliParameter.AUTH_PACKAGE_NAME;
        return packageInfo;
    }

    private Signature[] getSignature() {
        if (sSignature != null) {
            return sSignature;
        }
        sSignature = SignatureUtil.getSignatures(packageManager, realPackageName);
        return sSignature;
    }
    ...
}

PackageManager 也是一个抽象类,需要实现很多个方法,但这些方法大多数我们都用不上,所以可以忽略,只将 getPackageInfo() 方法实现即可,主要就是封装包名与签名。

接下来就要在 AliApplication 中调用:

public class AliApplication extends Application {
    ...
    @Override
    public PackageManager getPackageManager() {
        return new AliPackageManager(super.getPackageManager(), super.getPackageName());
    }
}

这个时候调用:

AliApplication application = new AliApplication(context);
PhoneNumberAuthHelper mPhoneNumberAuthHelper = PhoneNumberAuthHelper.getInstance(application, tokenResultListener);
mPhoneNumberAuthHelper.setAuthSDKInfo(AliParameter.AUTH_KEY);
mPhoneNumberAuthHelper.getReporter().setLoggerEnable(true);
mPhoneNumberAuthHelper.checkEnvAvailable(PhoneNumberAuthHelper.SERVICE_TYPE_AUTH);

你就发现,应用崩溃了…

实际上还漏了一个方法:

public class AliApplication extends Application {
    ...
    @Override
    public Context getApplicationContext() {
        return this;
    }
}

你可能会有疑惑,为什么不需要重写 ApplicationgetPackageName()。只不过 SDK 似乎都是通过 PackageManager 来获取的,实际上你重写也没有问题。

再运行,你会发现,应用没有崩溃,但是却没有拉起授权页,SDK 回调了以下错误:

{
    "code":"600002",
    "msg":"唤起授权页失败",
    ...
}

这个错误信息你查文档的话并没有什么作用,但是它能够告诉我们签名和包名都已获取正确了,也就是上面的步骤操作正确。

接下来就要开始对 SDK 进行魔改了。

在进行这一步之前,我必须要提个醒,阿里云提供的接入文档中有相关服务声明:

注意:

  1. 一键登录或注册需用户确认授权方可使用,开发者不得通过任何技术手段跳过或模拟此步骤,否则我方有权停止服务并追究相关法律责任。

  2. 登录按钮文字描述必须包含“登录”或“注册”等文字,不得诱导用户授权。

  3. 对于接入移动认证SDK并上线的应用,我方会对上线的应用授权页面做审查,如果有出现未按要求弹出或设计授权页面的,将关闭应用的一键登录或注册服务。

以下内容仅作学习交流,如产生任何责任由应用的开发者承担。

回到正题,魔改的话要从哪里入手?

不难注意到接入流程要我们手动注册三个 Activity,很明显,LoginAuthActivity 就是我们的目标,而且你也发现有两个 LoginAuthActivity

另一方面,我们无法正常唤起授权页,证明唤起的流程也需要修改。

这两个问题合在一起思考,我们能不能在修改唤起流程的时候,将两个 LoginAuthActivity 合并,即无论是移动、联通还是电信,都只唤起同一个 LoginAuthActivity,因为同样的功能分两个页面我实在想不到原因。

想要篡改,首先就得弄清楚流程,先反编译看看代码。

根据包名可以定位到是「phoneNumber-L-AuthSDK.arr」这个库里面的,解压拿到 Jar 包丢进之前在『Android 反编译入门指南』一文中提到的工具『JADX』里面就可以了,但反编译后我瞬间就懵了,阿里云对这个 SDK 做了混淆,并且还有一些方法写在了 SO 库中,大大增加了我们修改的难度。

通过代码搜索,最终定位到唤起流程在一个混淆名为 d 的类中:

public class d {
    ...
    public void a(long j2, String str, String str2, ResultCodeProcessor resultCodeProcessor, e eVar) {
        ...
        try {
            ...
            Intent intent = new Intent(this.c, LoginAuthActivity.class);
            intent.putExtra(Constant.LOGIN_ACTIVITY_NUMBER, str);
            intent.putExtra(Constant.LOGIN_ACTIVITY_VENDOR_KEY, str2);
            intent.putExtra(Constant.LOGIN_ACTIVITY_UI_MANAGER_ID, this.j);
            intent.putExtra(Constant.START_TIME, System.currentTimeMillis());
            ...
            if (f().getAuthPageActIn() == null || f().getActivityOut() == null) {
                if (activity != null) {
                    activity.startActivityForResult(intent, 1);
                } else {
                    intent.addFlags(268435456);
                    this.c.startActivity(intent);
                }
            } else if (activity != null) {
                ...
                if (TextUtils.isEmpty(authPageActIn) || TextUtils.isEmpty(activityOut)) {
                    SupportJarUtils.startActivityForResult(activity, intent, 1, (String) null, (String) null);
                } else {
                    SupportJarUtils.startActivityForResult(activity, intent, 1, authPageActIn, activityOut);
                }
            } else {
                intent.addFlags(268435456);
                this.c.startActivity(intent);
            }
            ...
        } catch (Exception e2) {
            ...
        } catch (Throwable th) {
            ...
        }
    }
}

中间那一堆判断我也不知道它有啥用,干脆直接去掉:

public class d {
    ...
    public void a(long l, String number, String vendor, ResultCodeProcessor resultCodeProcessor, com.mobile.auth.gatewayauth.e eVar) {
        try {
            this.t = l;
            Application application = ReflectionUtils.getApplication();
            if (application != null) {
                application.registerActivityLifecycleCallbacks(this.v);
            }
    
            Activity activity = ReflectionUtils.getActivity();
            Intent intent = new Intent(activity, LoginAuthActivity.class);
            intent.putExtra("isAli", true);
            intent.putExtra("number", number);
            intent.putExtra("vendor", vendor);
            intent.putExtra("ui_manager_id", this.j);
            intent.putExtra("startTime", System.currentTimeMillis());
            this.a(resultCodeProcessor);
            b.put(this.j, this);
    
            activity.startActivityForResult(intent, AliParameter.ACTIVITY_REQUEST_CODE);
    
            if (eVar != null) {
                eVar.a(vendor, number);
            }
        } catch (Throwable throwable) {
            com.mobile.auth.gatewayauth.a.a(throwable);
        }
    }
}

不管三七二十一,反正 Intent 封装好了,那就直接拉起 LoginAuthActivity 吧,为了更好的处理回调,这里通过 startActivityForResult() 拉起。

然后处理 LoginAuthActivity,对于 Activity 一般先看 onCreate() 方法:

@AuthNumber
public class LoginAuthActivity extends Activity {
    ...
    public native void onCreate(Bundle bundle);
}

可惜的是,LoginAuthActivity 直接把 onCreate() 写在了 SO 库中,所以我们不能直接修改,不要紧,幸好还有别的生命周期:

@AuthNumber
public class LoginAuthActivity extends Activity {
    ...
    public void onResume() {
        try {
            super.onResume();
        } catch (Throwable th) {
            a.a(th);
        }
    }
}

可以看到 SDK 里的 onResume() 并没有做什么操作。

首先我们需要写一个自己的界面来处理登录回调,我这里就用一个 TextView 来显示手机号码,一个 Button 来点击登录,还有一个 TextView 用来显示运营商协议,太简单我就不写了。

因为 SDK 本来就有授权页,所以我们要覆盖它,可以通过 LayoutInflater 来操作:

@AuthNumber
public class LoginAuthActivity extends Activity {
    ...
    public void onResume() {
        super.onResume();
        ViewGroup viewGroup = (ViewGroup) findViewById(android.R.id.content);
        View view = LayoutInflater.from(this).inflate(R.layout.activity_login_auth, null);
        viewGroup.addView(view);
        TextView tvPhone = (TextView) view.findViewById(R.id.tv_phone);
        tvPhone.setText(mNumberPhone);
        view.findViewById(R.id.btn_auth).setOnClickListener(v -> {
            onLogin();
        });
        TextView tvContract = (TextView) view.findViewById(R.id.tv_contract);
        String url = "";
        if (mSlogan.contains("移动")) {
            tvContract.setText("中国移动认证服务条款");
            url = AliParameter.PHONE_AUTH_YIDONG_CONTRACT;
        } else if (mSlogan.contains("联通")) {
            tvContract.setText("中国联通认证服务条款");
            url = AliParameter.PHONE_AUTH_LIANTONG_CONTRACT;
        } else if (mSlogan.contains("电信")) {
            tvContract.setText("天翼账号服务与隐私协议");
            url = AliParameter.PHONE_AUTH_DIANXIN_CONTRACT;
        } else {
            tvContract.setVisibility(View.GONE);
        }
        Uri uri = Uri.parse(url);
        Intent intent = new Intent(Intent.ACTION_VIEW, uri);
        tvContract.setOnClickListener(v -> startActivity(intent));
    }
    
    public void onLogin() {
        boolean access$400 = LoginAuthActivity.access$400(this);
        LoginAuthActivity.access$200(this).a(LoginAuthActivity.access$100(this), true, access$400);
        showLoadingDialog();
        LoginAuthActivity.access$700(this).a("LoginAuthActivity", "; PhoneNumberAuthHelper2 = ", String.valueOf(LoginAuthActivity.access$200(this)));
        LoginAuthActivity.access$200(this).b(LoginAuthActivity.access$200(this).a());
    }
    
    public void showLoadingDialog() {
        LoginAuthActivity.access$000(LoginAuthActivity.this).setClickable(true);
        access$200(this).a((TokenResultListener) new MyTokenResultListener(this));
    }
}

手机号码实际上展示的是掩码,和文章开头的各家应用截图一致。运营商协议则根据 mSlogan 这个字段处理一下,三大运营商的协议我已经把链接抓下来了,跳转到浏览器打开就可以。

按钮点击响应执行登录事件,并回调给 TokenResultListener

可以发现,这里的 TokenResultListener 和前面初始化的 TokenResultListener 并不是同一个,因为初始化的那个 TokenResultListener 我没有想到优雅的办法传递过来。

看看授权完成的 TokenResultListener 回调处理:

public class MyTokenResultListener implements TokenResultListener {

    private final LoginAuthActivity mLoginAuthActivity;

    public MyTokenResultListener(LoginAuthActivity activity) {
        this.mLoginAuthActivity = activity;
    }

    @Override
    public void onTokenSuccess(String value) {
        TokenRet tokenRet = TokenRet.fromJson(value);
        if (tokenRet.getCode().equals(ResultCode.CODE_SUCCESS)) {
            doLogin(tokenRet.getToken());
        }
    }

    @Override
    public void onTokenFailed(String value) {
        TokenRet tokenRet = TokenRet.fromJson(value);
        LogUtil.e("onTokenFailed TokenRet Msg: " + tokenRet.getMsg());
    }
    
    private void doLogin(String authToken) {
        // 请求服务端完成登录操作
        ...
        mLoginAuthActivity.setResult(Activity.RESULT_OK);
        mLoginAuthActivity.finish();
    }
}

这个 onTokenSuccess() 只需处理获取 TokenCode 即可,解析对应的 Token,并回传给服务端,完成登录流程,可以参考文档提供的流程图。

阿里云一键登录的系统交互流程

己方服务端登录成功后,再销毁 LoginAuthActivity,不要忘了将结果告知拉起授权页的 Activity,也不要忘了在拉起授权页的 Activity 处理结果:

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == AliParameter.ACTIVITY_REQUEST_CODE && resultCode == RESULT_OK) {
        // 登录完成
    }
}

最后再看看一开始初始化时的 TokenResultListener

TokenResultListener tokenResultListener = new TokenResultListener() {
    @Override
    public void onTokenSuccess(String s) {
        TokenRet tokenRet = TokenRet.fromJson(s);
        switch (tokenRet.getCode()) {
            case ResultCode.CODE_ERROR_ENV_CHECK_SUCCESS:
                // 支持使用本机号码一键登录
                mPhoneNumberAuthHelper.getLoginToken(application, 5000);
                break;
            case ResultCode.CODE_START_AUTHPAGE_SUCCESS:
                // 唤起授权页成功
                break;
            case ResultCode.CODE_SUCCESS:
                // 实际上并不会走这里回调
                break;
        }
    }

    @Override
    public void onTokenFailed(String s) {
        TokenRet tokenRet = TokenRet.fromJson(s);
        if (ResultCode.CODE_ERROR_USER_CANCEL.equals(tokenRet.getCode())) {     
            // 用户手动取消登录
        }
    }
};

修改完之后,我们要将原来「phoneNumber-L-AuthSDK.arr」这个库文件重新导入,因为重写了 LoginAuthActivityd 两个类,所以需要把库文件中对应的 CLASS 文件删除掉。

之前『「Android Studio」如何导入 AAR 包』一文提到,AAR 是以 Zip 格式构建的,所以可以直接通过压缩工具打开将其删除,也可以改用文章中提到的 JAR 的方式接入,解压后将资源放到对应的目录即可,由『「Android Studio」如何导入 JAR 包』一文可知,JAR 也是以 Zip 格式构建的,所以同样也可以用压缩软件打开并删除掉对应的 CLASS 文件。

而我们所修改的 LoginAuthActivityd,则根据原来 SDK 内的包名,放到项目中对应的包目录下即可。

最终运行效果如下:

最终运行效果

关于后面魔改 SDK 的相关代码,文章讲的比较简单,所以我写了个 Demo 放在 Github,需要的话可以参考我的 Demo 进行修改。


参考内容