现在越来越多的 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 是如何检测包名的,很简单,跟我们获取包名的操作类似,通过调用 Context
的 getPackageName()
来获取,再与服务端校验。
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
的实现非常多,但往往继承越深,代码也就越复杂,根据开发经验,继承自 Context
的 Application
对于获取这种全局的东西往往会比其他组件要稍微方便一些,所以可以自定义一个 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;
}
}
你可能会有疑惑,为什么不需要重写 Application
的 getPackageName()
。只不过 SDK 似乎都是通过 PackageManager
来获取的,实际上你重写也没有问题。
再运行,你会发现,应用没有崩溃,但是却没有拉起授权页,SDK 回调了以下错误:
{
"code":"600002",
"msg":"唤起授权页失败",
...
}
这个错误信息你查文档的话并没有什么作用,但是它能够告诉我们签名和包名都已获取正确了,也就是上面的步骤操作正确。
接下来就要开始对 SDK 进行魔改了。
在进行这一步之前,我必须要提个醒,阿里云提供的接入文档中有相关服务声明:
注意:
一键登录或注册需用户确认授权方可使用,开发者不得通过任何技术手段跳过或模拟此步骤,否则我方有权停止服务并追究相关法律责任。
登录按钮文字描述必须包含“登录”或“注册”等文字,不得诱导用户授权。
对于接入移动认证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()
只需处理获取 Token
的 Code
即可,解析对应的 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」这个库文件重新导入,因为重写了 LoginAuthActivity
和 d
两个类,所以需要把库文件中对应的 CLASS 文件删除掉。
之前『「Android Studio」如何导入 AAR 包』一文提到,AAR 是以 Zip 格式构建的,所以可以直接通过压缩工具打开将其删除,也可以改用文章中提到的 JAR 的方式接入,解压后将资源放到对应的目录即可,由『「Android Studio」如何导入 JAR 包』一文可知,JAR 也是以 Zip 格式构建的,所以同样也可以用压缩软件打开并删除掉对应的 CLASS 文件。
而我们所修改的 LoginAuthActivity
和 d
,则根据原来 SDK 内的包名,放到项目中对应的包目录下即可。
最终运行效果如下:
关于后面魔改 SDK 的相关代码,文章讲的比较简单,所以我写了个 Demo 放在 Github,需要的话可以参考我的 Demo 进行修改。