上篇文章已经介绍了 JWT 认证在 Laravel 框架服务器上的实现。这篇文章继续介绍 Android 客户端的实现。回顾下 JWT 认证的流程,客户端先提交账号密码进行登录,账号密码验证成功后,服务器会生成一个 token,其中包含了用户信息,token 到期时间等信息,服务器将 token 返回给客户端后不会保存此 token。客户端接受到 token 后,需要对 token进行存储,在以后访问需要认证的 API 接口是,在 HTTP 请求通过认证头提交 token,服务器校验 token 的合法性,是否过期,携带的用户信息是否匹配,全部通过后,完成验证,之后才能完成后续操作。
先看一下已经实现的 API 接口的路由:
$api = app('Dingo\Api\Routing\Router');
$api->version('v1', ['namespace' => 'App\Http\Controllers'], function ($api) {
$api->get('login', 'Auth\AuthenticateController@authenticate');
$api->post('register', 'Auth\RegisterController@register');
$api->group(['middleware' => 'jwt.auth', 'providers' => 'jwt'], function ($api) {
$api->get('user', 'UserController@getUserInfo');
$api->get('notices', 'NoticeController@index');
});
});
其中 login 和 register 是用来获取 token 的,而 user 和 notices 则需要客户端提供 token 。下面我们就在 Android 客户端上实现对这些接口的访问。
我们继续在 Jokes 项目上进行开发,Jokes 采用 MVP + Retrofit + RxJava 的架构,具体细节可以参考我之前的两篇文章:
使用MVP+Retrofit+RxJava实现的的Android Demo (上)使用Nuclues库实现MVP
使用MVP+Retrofit+RxJava实现的的Android Demo (下)使用Retrofit+RxJava处理网络请求
本文采用的 Android 代码下载地址:
https://github.com/zhongchenyu/jokes
由于后续可能会重构代码,本文使用的代码保存在 demo2 分支。
在主页新增一页 MoreFragment,布局文件代码如下:
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_height="match_parent"
tools:context="chenyu.jokes.feature.more.MoreFragment" android:orientation="vertical"
android:background="@color/bgGrey">
<RelativeLayout
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:background="@android:color/white">
<ImageView android:id="@+id/avatar"
android:layout_width="80dp" android:layout_height="80dp"
android:layout_alignParentStart="true" app:srcCompat="@drawable/ic_36"
android:layout_marginStart="16dp" android:layout_marginTop="16dp"
android:layout_centerVertical="true" android:adjustViewBounds="false"/>
<TextView android:id="@+id/name"
android:layout_width="wrap_content" android:layout_height="32dp"
android:visibility="invisible" android:textSize="24sp"
android:layout_toEndOf="@+id/avatar" android:layout_marginStart="16dp"
android:layout_alignParentTop="true" android:layout_marginTop="8dp"/>
<TextView android:id="@+id/email"
android:layout_width="wrap_content" android:layout_height="32dp"
android:textSize="16sp" android:visibility="invisible"
android:layout_toEndOf="@+id/avatar" android:layout_marginStart="16dp"
android:layout_marginTop="8dp" android:layout_below="@+id/name"/>
<Button android:id="@+id/login"
android:text="登录"
android:layout_width="72dp" android:layout_height="32dp"
android:layout_toEndOf="@+id/avatar" android:layout_centerVertical="true"
android:layout_marginStart="32dp" android:padding="0dp"
android:textColor="@android:color/white" android:textSize="16sp"
android:background="@drawable/selector_bg_corner"/>
<Button android:id="@+id/register"
android:text="注册" android:padding="0dp"
android:layout_width="72dp" android:layout_height="32dp"
android:background="@drawable/selector_bg_corner" android:layout_toEndOf="@+id/login"
android:textColor="@android:color/white" android:textSize="16sp"
android:layout_centerVertical="true" android:layout_marginStart="16dp"/>
<Button android:id="@+id/logout"
android:text="退出" android:padding="0dp"
android:layout_width="72dp" android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:background="@drawable/selector_bg_corner" android:visibility="invisible"
android:textColor="@android:color/white" android:textSize="16sp"
android:layout_alignParentEnd="true" android:layout_centerVertical="true"/>
RelativeLayout>
<LinearLayout
android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="horizontal" android:layout_marginTop="8dp"
android:background="@android:color/white">
<Button android:id="@+id/notice"
android:text="获取通知"
android:layout_width="wrap_content" android:layout_height="wrap_content"
android:enabled="false" android:layout_gravity="top"
android:background="@drawable/selector_bg_corner" android:layout_marginTop="16dp"
android:textColor="@android:color/white" android:textSize="16sp"
android:layout_marginStart="16dp" android:layout_marginBottom="16dp"/>
<TextView android:id="@+id/notice_content"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:textSize="16sp" android:layout_marginStart="16dp"
android:layout_marginTop="16dp"/>
LinearLayout>
LinearLayout>
登录之前效果如下,界面显示登录和注册按钮,获取通知按钮为不可点击状态。
登录后效果如下,登录和注册按钮隐藏,变为显示用户名和邮箱,退出按钮也被显示出来,并且获取通知按钮变为可以点击。
在 ServiceAPI 下添加网络接口:
@FormUrlEncoded @POST("register") Observable register(
@Field("name") String name,
@Field("email") String email,
@Field("password") String password
) ;
我们用的是 MVP 架构,网络请求是在 Presenter 中完成的,那么在 MorePresenter 的 onCreate 函数中注册请求:
restartableFirst(REGISTER,
new Func0>() {
@Override public Observable call() {
return App.getServerAPI().register(mName, mEmail, mPassword) .subscribeOn(Schedulers.newThread()).observeOn(AndroidSchedulers.mainThread());
}
},
new Action2() {
@Override public void call(MoreFragment moreFragment, Token token) {
moreFragment.onRegisterSuccess(token);
}
}, new Action2() {
@Override public void call(MoreFragment moreFragment, Throwable throwable) {
moreFragment.onError(throwable);
}
}
);
调用 register 网络接口,在请求成功调用moreFragment 的 onRegisterSuccess 函数。
同时在 MorePresenter 中公开一个 register 函数,供 View 层来调用,发起网络请求:
public void register(String name, String email, String password) {
mName = name;
mEmail = email;
mPassword = password;
start(REGISTER);
}
然后是 View 层的实现,MoreFragment 中对注册按钮添加监听,点击后弹出对话框进行注册:
@OnClick({R.id.login, R.id.logout, R.id.register, R.id.notice}) public void click(View view) {
switch (view.getId()) {
...
case R.id.register:
showRegisterDialog();
break;
}
}
在看下 showRegisterDialog() 函数:
private void showRegisterDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
builder.setIcon(R.mipmap.ic_launcher).setTitle("注册");
View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_register, null);
builder.setView(view);
final EditText edtUserName = (EditText) view.findViewById(R.id.username);
final EditText edtPassword = (EditText) view.findViewById(R.id.password);
final EditText edtEmail = (EditText) view.findViewById(R.id.email);
final EditText edtPasswordConfirm = (EditText) view.findViewById(R.id.password_confirmation);
builder.setPositiveButton("确定", null);
builder.setNegativeButton("取消", new DialogInterface.OnClickListener() {
@Override public void onClick(DialogInterface dialog, int which) {
}
});
final AlertDialog alertDialog = builder.create();
alertDialog.show();
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(
new View.OnClickListener() {
@Override public void onClick(View v) {
String userName = edtUserName.getText().toString().trim();
String password = edtPassword.getText().toString().trim();
String email = edtEmail.getText().toString().trim();
String password_confirm = edtPasswordConfirm.getText().toString().trim();
if(! password.equals(password_confirm) ) {
Toast.makeText(getContext(), "两次输入密码不一致", Toast.LENGTH_SHORT).show();
return;
}
getPresenter().register(userName, email, password);
alertDialog.dismiss();
}
});
}
AlertDialog 采用了自定义的 layout,包含 用户名、邮箱、密码、确认密码 这4个文本编辑框。我们给确定按钮注册了一个空的监听器,这是因为在点击确定时要验证密码和确认密码是否相同,如果不同,弹出提示消息,对话框不会消失,这样用户才有机会进行修改,如果监听器不是 null,那用户点击确定后对话框必定会消失。所以这里给确定按钮注册一个空的 DialogInterface.OnClickListener,并在对话框显示出来给,查找到确认按钮,并注册一个 View.OnClickListener,来实现上述需求。
如果两次密码确认一致,则调用 Presenter 中的 register 函数,并取消对话框。
注册成功后的相应比较简单,直接弹出提示:
public void onRegisterSuccess(Token token) {
Toast.makeText(getContext(), "注册成功,请登录", Toast.LENGTH_SHORT).show();
}
密码输入一致,点击确定,发起注册请求,对话框消失,提示注册成功:
先看一下登录接口返回的数据,其中包含了用户信息和 token:
{
"user": {
"id": 9,
"name": "user666",
"email": "[email protected]"
},
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjksImlzcyI6Imh0dHA6XC9cL2hvbWVzdGVhZC5hcHBcL2FwaVwvbG9naW4iLCJpYXQiOjE0OTM3NTQ0NjUsImV4cCI6MTQ5Mzc1ODA2NSwibmJmIjoxNDkzNzU0NDY1LCJqdGkiOiJGeTRmb2FYeWI5Q2RZTGlXIn0.Isu2XpPypZIMjB8P8Fis-qLknij6hdWfaQ_Jl1Gzo-o"
}
登录功能和注册功能很相似,但是登录成功后我们要根据服务器返回的用户信息更新UI,并对 token进行存储。
首先在 Model 路径下创建 User 类和 Account 类用于解析和存储网络数据:
@JsonIgnoreProperties(ignoreUnknown = true) public class User {
public String id;
public String name;
public String email;
}
@JsonIgnoreProperties(ignoreUnknown = true) public class Account {
public User user;
public String token;
}
ServiceAPI 增加 网络接口,我们用 Account类来解析接口返回的 Json数据:
@GET("login") Observable login(
@Query("email") String email,
@Query("password") String password
) ;
接下来在 MorePresenter 的 onCreate 中注册网络请求:
restartableFirst(LOGIN,
new Func0>() {
@Override public Observable call() {
return App.getServerAPI().login(mEmail, mPassword)
.subscribeOn(Schedulers.newThread()).observeOn(AndroidSchedulers.mainThread());
}
},
new Action2() {
@Override public void call(MoreFragment moreFragment, Account account) {
moreFragment.onLoginSuccess(account);
}
},
new Action2() {
@Override public void call(MoreFragment moreFragment, Throwable throwable) {
moreFragment.onError(throwable);
}
}
);
网络请求成功后会调用 MoreFragment 的 onLoginSuccess 函数。
同时在 MorePresenter 中公开 login 函数供外部调用:
public void login(String email, String password) {
mEmail = email;
mPassword = password;
start(LOGIN);
}
接下来是 View 层处理,在 MoreFragment 中,点击登录按钮后,弹出登录对话框:
@OnClick({R.id.login, R.id.logout, R.id.register, R.id.notice}) public void click(View view) {
switch (view.getId()) {
case R.id.login:
showLoginDialog();
break;
...
}
}
private void showLoginDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
builder.setIcon(R.mipmap.ic_launcher).setTitle("登录");
View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_login, null);
builder.setView(view);
final EditText edtPassword = (EditText) view.findViewById(R.id.password);
final EditText edtEmail = (EditText) view.findViewById(R.id.email);
builder.setPositiveButton("确定", new DialogInterface.OnClickListener() {
@Override public void onClick(DialogInterface dialog, int which) {
String password = edtPassword.getText().toString().trim();
String email = edtEmail.getText().toString().trim();
getPresenter().login( email, password);
}
});
builder.setNegativeButton("取消", new DialogInterface.OnClickListener() {
@Override public void onClick(DialogInterface dialog, int whick) {
}
});
builder.show();
}
因为不需要做校验,登录对话框比注册时简单,点击确定后就调用 MorePresenter 的 login 函数,发送登录请求。
再看一下登录成功后的处理:
public void onLoginSuccess(Account account) {
AccountManager.create().setAccount(account);
mTxtName.setVisibility(View.VISIBLE);
mTxtName.setText(account.user.name);
mTxtEmail.setVisibility(View.VISIBLE);
mTxtEmail.setText(account.user.email);
mBtnLogin.setVisibility(View.INVISIBLE);
mBtnLogout.setVisibility(View.VISIBLE);
mBtnRegister.setVisibility(View.INVISIBLE);
mBtnNotice.setEnabled(true);
}
首先对账号信息进行存储,包含用户的 ID、name、email,以及此次的 token,这些信息会被保存到 SharedPreferences 里,AccountManager 是我们自定义的账号管理类,可以在应用的任何地方存储和获取用户信息,具体在下一节中介绍。
然后就是 UI 的变更了,登录成功后将登录和注册按钮隐藏,显示用户的 name 和email,显示退出按钮,将获取通知按钮设置为可点击。
JWT 的 token 的有效期一般设置为数小时,Laravel 下的 JWT 默认有效期为60分钟。在这期间客户端需要对 token 进行存储,那么存储在什么位置合适呢?因为 应用中任何位置都有可能访问需要认证的 API,这个 token 需要在应用全局可用,不会随着 Fragment 或者 Activity 的生命周期而消亡,并且在应用退出后也需要保留。
综合考虑上面的需求,决定将账户信息保存到 SharedPreferences 中,由于使用 SharedPreferences 需要用到 context,因此在 Application 类中提供一个获取全局 context 的方法,以便在任何地方都可以调用 AccountManager 类。
在 App 类下:
private static Context context;
@Override public void onCreate(){
super.onCreate();
context = getApplicationContext();
...
}
public static ServerAPI getServerAPI() {
return serverAPI;
}
public class AccountManager {
private static SharedPreferences sp;
private static SharedPreferences.Editor editor;
public static AccountManager create() {
AccountManager accountManager = new AccountManager();
accountManager.sp = App.getAppContext().getSharedPreferences("account", 0);
accountManager.editor = sp.edit();
return accountManager;
}
public void setToken(String token) {
editor.putString("token", token);
editor.commit();
}
public String getToken() {
String token = sp.getString("token", "");
return token;
}
public void setAccount(Account account) {
editor.putString("token", account.token);
editor.putString("userId", account.user.id);
editor.putString("userEmail", account.user.email);
editor.putString("userName", account.user.name);
editor.commit();
}
public Account getAccount() {
Account account = new Account();
account.token = sp.getString("token", "");
account.user.id = sp.getString("userId", "");
account.user.name = sp.getString("userEmail", "");
account.user.email = sp.getString("userEmail", "");
return account;
}
public void clearAccount() {
editor.putString("token", "");
editor.putString("userId", "");
editor.putString("userEmail", "");
editor.putString("userName", "");
editor.commit();
}
public void setUser(User user) {
editor.putString("userId", user.id);
editor.putString("userEmail", user.email);
editor.putString("userName", user.name);
editor.commit();
}
}
代码比较简单,提供一个静态函数 create 来创建并返回 AccountManager,同时做好 SharedPreferences 存取的准备工作,这里用到了 App 里的getAppContext() 函数来获取全局 context。之后提供了对账号Account
的存储、读取和清除函数,也可以单独存取 User 和 token。
获取到 token 之后就可以访问 需要认证的 API 了,服务器已经准备好了两个 API,一个是简单测试用的 notices API,认证成功就返回一段话,还有一个就是 user API,认证成功后返回 User 信息,user API 当前用来做校验 token 是否有效使用,在后面的章节介绍,这一节只介绍 notices API。
首先添加 Model,在 ServiceAPI 下创建网络接口:
public class Notice {
public String content;
}
@GET("notices") Observable getNotice(
@Header("Authorization") String token
) ;
注意和之前的接口不同,这里添加了 @Header 注解,这样发送网络请求时会添加认证头。
接下来 MorePresenter 注册请求,公开函数,和之前的基本类似,不同的是在访问 API 接口时,调用了 AccountManager 来获取 token,注意 token 前加了 Bearer:
restartableFirst(NOTICE,
new Func0>() {
@Override public Observable call() {
return App.getServerAPI().getNotice("Bearer " + AccountManager.create().getToken())
.subscribeOn(Schedulers.newThread()).observeOn(AndroidSchedulers.mainThread());
}
},
new Action2() {
@Override public void call(MoreFragment moreFragment, Notice notice) {
moreFragment.onGetNoticeSuccess(notice);
}
},
new Action2() {
@Override public void call(MoreFragment moreFragment, Throwable throwable) {
moreFragment.onError(throwable);
}
}
);
public void getNotice() {
start(NOTICE);
}
然后是 View 层处理,也很简单,点击获取通知按钮,调用 MorePresenter 的 getNotice函数,请求成功后,显示获取的通知消息:
@OnClick({R.id.login, R.id.logout, R.id.register, R.id.notice}) public void click(View view) {
switch (view.getId()) {
...
case R.id.notice:
getPresenter().getNotice();
break;
}
}
public void onGetNoticeSuccess(Notice notice) {
mTxtNotice.setText(notice.content);
}
最后看下效果,登录成功后获取通知:
假如 token 已经过期,我们再取点击按钮,则无法通过认证:
因为 JWT 是无状态无连接的认证方式,服务器上不需要保存 token 状态,因此退出时只需要清除掉客户端本地的账号信息就行了,不需要和服务器作交互。
看下实现代码,调用 AccountManager 清除掉存储的账号信息,并恢复 UI 到登录前的样子就行了。
@OnClick({R.id.login, R.id.logout, R.id.register, R.id.notice}) public void click(View view) {
switch (view.getId()) {
case R.id.logout:
AccountManager.create().clearAccount();
mBtnLogin.setVisibility(View.VISIBLE);
mBtnLogout.setVisibility(View.INVISIBLE);
mBtnRegister.setVisibility(View.VISIBLE);
mBtnNotice.setEnabled(false);
mTxtName.setVisibility(View.INVISIBLE);
mTxtEmail.setVisibility(View.INVISIBLE);
mTxtNotice.setText("");
break;
}
}
上面的代码已经实现了登录成功后用户信息和 token 的存储,那么我们希望在应用或者特定的 View 启动的时候,能够将存储的用户信息恢复到 UI 上,并且检测下存储的 token 是否有效,是否过期,如果未过期,则自动恢复 UI 到已登录的状态,不需要用户再登录。综上,我们在 MoreFragment 启动的时候,访问 user API 接口,携带存储的 token,给服务器验证,如果验证成功,则恢复 UI 到登录成功后的样子,如果验证失败,则保留未登录的状态,等待用户再次输入账号密码进行登录。
要实现上述功能,和之前的代码一样的,首先创建好 Model、ServiceAPI 接口、MorePresenter中注册好请求,具体代码就不贴了,都是类似的。主要看下 MoreFragment 的代码,我们在 onCreateView 里处理:
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_more, container, false);
ButterKnife.bind(this, view);
if(AccountManager.create().getToken() != "") {
getPresenter().getUserInfo();
}
return view;
}
首先通过 AccountManager 获取存储的 token,如果 token 是空的,说明之前就是未登录状态,不需要处理,UI 或保持初始的未登录状态,如果 token 非空,则调用 MorePresenter 来访问 user API。
如果认证失败,则弹出提示,UI 不会有变化,保持未登录状态。如果认证成功,则调用 MoreFragment 的 onGetUserSuccess 函数来更新UI,这里恢复 UI 时用户信息的来源可以是本地 SharedPreferences,也可以是服务器刚返回的数据,正常情况下两者应该是一样的,但是我们认为服务器的数据更可信,因而采用服务器的数据更新 UI,并将服务器的 User 数据进行存储。
public void onGetUserSuccess(User user) {
AccountManager.create().setUser(user);
mTxtName.setVisibility(View.VISIBLE);
mTxtName.setText(user.name);
mTxtEmail.setVisibility(View.VISIBLE);
mTxtEmail.setText(user.email);
mBtnLogin.setVisibility(View.INVISIBLE);
mBtnLogout.setVisibility(View.VISIBLE);
mBtnRegister.setVisibility(View.INVISIBLE);
mBtnNotice.setEnabled(true);
}
为了测试效果,我们特意将服务器上的 token 有效期配置为1分钟,修改服务器的 .env 文件,设置 JWT_TTL=1 。
看下效果,登录成功或退出应用,在 token 过期前重新启动应用,进入 MoreFragment 页面,自动进入已登录状态:
再次退出应用,等 token 过期后,启动应用,提示未认证,进入 MoreFragment 页面,处于未登录状态:
JWT 方式的 API 基本功能,以及 Laravel 服务器和 Android 客户端的实现方式就介绍完了,JWT 这种无状态的方式还是很适合 API 认证的,客户端只需要生成和验证 token,客户端只需要存储 token 就行,token 有效期就存储在 token 自身,不需要服务器为每个登录的用户去存储 token 状态,这样大大减小了开销。并且 token 本身就包含了用户 ID 等一些非敏感信息,因此在很多网络请求的时候,甚至可以只传输 token,不需要再有单独的用户信息参数,也是减少了一笔开销。
上面介绍的内容可以完成 JWT 认证的基本功能了,但还是有很多可以改善的地方,比如 password 是明文传输的,很不安全,这个作为一个 Demo 项目,就没考虑这么周全。另外由于 JWT 方式一个天生的缺点,服务器无法控制 token 的有效期,只要你发出了一个 token,它的有效期就定死了,因为服务器不存储 token 状态,所有就无法提前结束 token 生命周期。
因此在配置 token 有效期是要比较谨慎,不能太长了。但是太短也不行,因为 token 方式,包括除了 JWT 外的其他 token方式,其实就是用 token 代替账号密码作为用户验证的凭证,只要一次账号密码验证通过,后续一段时间内只需要 token 就可以验证,不需要密码,降低风险,有效期太短必然导致密码频繁发送,且用户需要频繁地登录,影响用户体验。所有要根据实际情况选择一个合适的有效期。
另外 token 到期后如何处理也是个问题。如果用户没使用应用的时候 token 过期了,那还好点,想想用户正在操作应用的时候,突然 token 就到期了,操作被中断,需要重新登录,那一定是一件很不爽的事情。JWT 本身也提供了一种解决方法,设置了一个 token 刷新时间,在 token 过期但是没超过刷新时间的情况下,用旧的 token 可以获取到新的 token。另外也可以考虑在每次发送 API 请求的时候都去刷新 token,或者周期性发送心跳包来更新 token,不过这在并发请求比较多的时候,也会涉及到异步冲突的问题,需要谨慎考虑。
后续如果有时间,再深入研究下这些问题。