如何实现 APK 的免密登录功能?
2019-12-19
关键字:自动保存密码、自动保存登录凭证
何谓免密登录?
免密登录就是针对需要使用通行证账号登录的应用,在一次登录成功以后,可以自动将通行证信息或登录令牌、缓存保存在本地,以便下次打开该应用时可以直接使用本地存储的登录信息自动登录,从而提升软件使用体验的机制。
所以,免密登录的本质就是保存登录信息。
那都有哪些信息要保存呢?
最基础最核心的就三点:
1、账号;
2、密码;
3、服务器返回的登录信息。
我们还必须考虑到,这些登录信息是保存在用户设备上的,它不受我们的直接控制,那为了提升间接可控性,还必须同时保存一些额外信息。这些额外信息通常是用于描述这份保存的通行证信息的,可以将它们理解成是数据库设计中的“metadata / 元数据信息”。它们可以有:
1、版本信息;
2、状态开关信息;
3、初始创建日期;
4、最后访问日期;
版本信息的作用主要是控制存储在用户设备上的信息的可用性。我们都知道,我们设计出来的数据存储结构多少是有些缺陷的。在我们对这个数据存储结构有变更的时候,就直接把版本信息升一下。这个软件版本信息除了存储在本地的那一份以外,在软件上还会有一份。在解析本地存储信息时比对一下版本号差异。当软件上固化的版本号与本地存储的数据块的版本号不对时,就可以不使用这份本地存储的信息,强制让用户重新做一次登录操作以更新新版本的数据块。遇到这种情况,软件只需要弹一个类似于“登录信息已过期”的提示语,用户一般都可以理解的。
状态开关的作用也是控制本地存储数据块的可用性的。
初始创建日期于最后访问日期的作用主要是标识同一份数据块的有效期的。一般而言,登录时服务器都会返回一个“令牌”,后续所有与服务器的交互都以这个令牌来作用户标识。而令牌是有有效期的。如果后台没有一个完善的令牌更新机制,那就需要我们软件自行来检测有效期了。比如,软件可以设置同一个令牌自创建之日起,最多可以使用30天。所以,初始创建日期就很有必要了。而最后访问日期的作用是用于实现“连续x天不登录,登录凭据自动失效”功能的。同时,考虑到用户设备可以随时更改设备时间的情况,这个最后访问日期还可以起到避免让用户通过更改设备时间以绕过“登录凭据超时”检测的目的。
当然,这些附加信息的种类是自由的,同学们完全可以根据自己的实际业务需求来设定。
好了,前面明确了“要存什么”。接下来要考虑的是“要怎么存”。
“要怎么存”其实并没有什么限制。简单的可以直接以文本文件的形式保存,复杂一点的可以通过加密数据库来保存。一切看你的需求。
笔者这边就是直接以文本文件的形式来保存。
当然,通行证数据是敏感数据,其保存的位置当然也要“敏感”一点。一般是直接保存在应用的内部目录下。什么是应用内部目录呢? mContext.getFilesDir() 得到的目录,即 /data/data/com.xxx.xxx/ 下的 files 目录等。这个路径下的数据仅能被自己的应用访问,且数据会随应用的卸载而删除,最适于保存敏感数据了。
然后,就是数据内容的加密了。加密是最重要的。虽然前面有说过可以直接以文本文件的形式保存,且笔者也是以文本文件的形式保存的,但并不意味着可以直接明文保存。即使是直接保存文本在本地,也是使用的加密以后的文本来保存的。
既然要加密就涉及到加密算法的选型问题。笔者对加密算法了解的并不多,甚至不清楚有哪些流行好用的开源加密算法可以使用。所以,关于加密算法就需要各位同学自行去研究了。
不过,也正因为笔者不知道哪些加密算法好。所以笔者本着学习的目的,干脆自己设计了一套加密算法。虽然它的可靠性可能很差,但用肯定还是可以用的,而且笔者设计的数据结构里有版本号的概念,后续如果需要对加密算法进行升级,也是非常简单的。
笔者设计的这套加密算法的流程就两步:
1、乱序;
2、映射;
乱序怎么乱呢?
要保存的登录信息肯定是串文本数据。笔者将这串数据封装成 JSON 格式的。然后将这串文本数据转换成字节码,按照一定的规则,将这串字节码的顺序打乱。例如,最简单的打乱方式就是“头尾交换”,第0字节的数据放到最后面去,最后面的字节放在第0位来。当然你也可以自定义一个乱序表,根据乱序表来确定哪个位置的数据换到哪里去。不过这种情况一定得保存好这份乱序表,因为如果没有它,你的数据将无法还原。
映射的原理其实就是乱序的变种版。也是根据一份映射表,表中有128个不重复的数据。将前面乱序以后的字节数组中每一个字节的 ASCII 码值作为映射表的索引。将映射表中对应索引号的数值填充到该字节中去,以实现将原始信息“抹去”的目的。在解密的时候再根据这份映射表来反映射就行了。
这套加密算法的核心就在乱序表与映射表的数值设计上。但它的加密能力总体来说还是很弱。用在一些数据价值不是很重要的场合倒也是没什么问题。
下面贴出笔者的这份免密登录功能的核心代码:
public class TokenManager { private static final String TAG = "TokenManager"; private static final int VERSION = 3; private static final String PUB_DATE = "2019-10-14 20:13"; private static final String COMPILER = "chorm"; /** * true 打开免密登录功能。 * */ private static final boolean SWITCH = true; private static final String TKFN = "tmpfile.tf"; public static final int ERR_NO_ERROR = 0; public static final int ERR_VERSION_UPGRADE = 1; public static final int ERR_EXPIRED = 2; private static final long DURATION_1_DAY = 86400 * 1000; // in milliseconds. private static final long DURATION_1_MONTH = DURATION_1_DAY * 30; /**Token 最长有效期。*/ private static final long TOKEN_VALID_MAX_DURATION = DURATION_1_MONTH; /**连续 n 天未登录,即必须重新登录。*/ private static final long TOKEN_VALID_MAX_INTERVAL = DURATION_1_DAY * 3; private static int errno; private static User user; /**用于算法版本升级时。*/ private static User userBak; private static File tkFile; public static void init() { errno = ERR_NO_ERROR; userBak = null; user = null; FileInputStream fis = null; TokenFileParser tokenFileParser = new TokenFileParser(); File tokenDir = FileManager.getTokenDir(); if(tokenDir == null) { Logger.d(TAG, "Cannot resolve the non-pwd directory."); return; } Logger.d(TAG, "token dir:" + tokenDir.getAbsolutePath()); // 1. Read file prepare. tkFile = new File(tokenDir.getAbsolutePath(), TKFN); Logger.d(TAG, "tkFile:" + tkFile.getAbsolutePath()); if(tkFile.exists() && tkFile.isFile()) { try { fis = new FileInputStream(tkFile); } catch(FileNotFoundException e) { e.printStackTrace(); } }else{ boolean ret = false; try { ret = tkFile.createNewFile(); } catch(IOException ex) { ex.printStackTrace(); } Logger.d(TAG, "Create tk file ret:" + ret); return; } // 2. Read file. if(fis != null) { String token = null; try { token = Encryption.decrypt(readTokenFile(fis)); }catch(Exception e){ } Logger.d(TAG, "The token that read:" + token); tokenFileParser.parse(token); try { fis.close(); } catch(IOException e) { e.printStackTrace(); } } } private static String readTokenFile(FileInputStream fis){ byte[] buf = new byte[2048]; int rlen = -1; StringBuilder sb = new StringBuilder(); while(true){ try { rlen = fis.read(buf); } catch(IOException e) { e.printStackTrace(); } if(rlen < 0){ break; } sb.append(new String(buf, 0, rlen)); } return sb.toString(); } public static User getUser(){ return SWITCH ? user : null; } public static User getUserBak(){ return SWITCH ? userBak : null; } public static void storagePassport(User user, boolean isAutoLogin) { if(user == null) { return; } JSONObject jobj = new JSONObject(); jobj.put("version", VERSION); jobj.put("pub_date", PUB_DATE); jobj.put("compiler", COMPILER); jobj.put("state", "1"); jobj.put("username", user.getUsername()); jobj.put("psw", user.getPsw()); if(isAutoLogin) { jobj.put("orgId", user.getOrganizationId()); jobj.put("uid", user.getUserid()); jobj.put("token", user.getToken()); } long ct = TokenFileParser.createTime; ct = ct == 0 ? System.currentTimeMillis() : ct; jobj.put("create_time", ct); jobj.put("access_time", System.currentTimeMillis()); try { FileOutputStream fos = new FileOutputStream(tkFile); fos.write(Encryption.encrypt(jobj.toJSONString())); fos.close(); } catch(Exception e) { e.printStackTrace(); } Logger.d(TAG, "Storage passport file finished!"); } public static void clearStoragedPassport() { if(tkFile.exists() && tkFile.isFile()) { boolean ret = tkFile.delete(); Logger.d(TAG, "Storage passport file delete " + (ret ? "success." : "failed.")); } user = null; } public static void tokenInvalid() { // 1. init init(); // 2. storage the user information without auto login but auto fill. if(user == null) { return; } storagePassport(user, false); // 3. set the static variable 'user' to null. user = null; } public static int getErrno(){ return errno; } private static class TokenFileParser extends JSONParser { static long createTime; void parse(String token){ if(token == null) { Logger.d(TAG, "No token file found."); return; } JSONObject tokenJobj = JSON.parseObject(token); String value = getJsonString(tokenJobj, "state"); if(!"1".equals(value)) { Logger.d(TAG, "Function was disabled."); return; } value = getJsonString(tokenJobj, "username"); if(value.isEmpty()) { Logger.d(TAG, "No username"); user = null; return; } user = new CommonUser(); user.setUsername(value); value = getJsonString(tokenJobj, "psw"); if(value.isEmpty()) { Logger.d(TAG, "No password"); user = null; return; } user.setPsw(value); //有效性检查 try { createTime = Long.parseLong(getJsonString(tokenJobj, "create_time")); long interval = System.currentTimeMillis() - createTime; Logger.d(TAG, "Current token create at:" + createTime + ",and now is at:" + System.currentTimeMillis() + ",interval:" + interval); if(interval > TOKEN_VALID_MAX_DURATION){ Logger.d(TAG, "Your token was expired."); errno = ERR_EXPIRED; userBak = user; userBak.setPsw(""); user = null; return; } }catch(NumberFormatException e){ Logger.w(TAG, "No create time found!"); errno = ERR_EXPIRED; userBak = user; userBak.setPsw(""); user = null; return; } //长时间未登录检查。 try{ long accessTime = Long.parseLong(getJsonString(tokenJobj, "access_time")); Logger.d(TAG, "Access time is:" + accessTime); if(accessTime < createTime){ Logger.d(TAG, "Access time or create time error."); errno = ERR_EXPIRED; user.setPsw(""); userBak = user; user = null; return; } if((System.currentTimeMillis() - accessTime) > TOKEN_VALID_MAX_INTERVAL){ Logger.d(TAG, "Access time was expire."); errno = ERR_EXPIRED; user.setPsw(""); userBak = user; user = null; return; } if(System.currentTimeMillis() < accessTime){ Logger.d(TAG, "Access time is in future."); errno = ERR_EXPIRED; user.setPsw(""); userBak = user; user = null; return; } }catch(NumberFormatException e){ Logger.d(TAG, "Access time format error."); errno = ERR_EXPIRED; user.setPsw(""); userBak = user; user = null; return; } int ivalue = tokenJobj.getIntValue("orgId"); if(ivalue <= 0) { Logger.d(TAG, "Organization invalid."); return; } user.setOrganizationId(ivalue); ivalue = tokenJobj.getIntValue("uid"); if(ivalue <= 0) { Logger.d(TAG, "uid invalid."); user.setOrganizationId(0); return; } user.setUserid(ivalue); value = getJsonString(tokenJobj, "token"); if(value.isEmpty()) { Logger.d(TAG, "No token found."); user.setUserid(0); user.setOrganizationId(0); return; } user.setToken(value); int version = tokenJobj.getIntValue("version"); if(version < TokenManager.VERSION){ Logger.d(TAG, "Current version of tkfile is small than this publish version," + version + " --> " + TokenManager.VERSION); errno = ERR_VERSION_UPGRADE; userBak = user; user = null; return; } } } // class TokenFileParser -- end. /** * 不可靠 * */ private static class Encryption { private static byte[] table = { 41, 58, 29, 53, 30, 4, 74, 2, 78, 116, 89, 111, 56, 57, 47, 114, 21, 27, 83, 16, 110, 124, 44, 46, 62, 120, 38, 71, 1, 108, 80, 12, 121, 10, 64, 73, 101, 119, 37, 86, 20, 87, 100, 85, 45, 61, 112, 59, 117, 126, 0, 77, 8, 42, 43, 52, 102, 123, 50, 93, 82, 69, 35, 72, 67, 23, 28, 17, 24, 88, 70, 125, 18, 26, 105, 94, 19, 15, 55, 95, 40, 39, 48, 81, 36, 79, 92, 103, 91, 63, 122, 60, 25, 5, 109, 34, 97, 11, 68, 104, 115, 84, 9, 6, 107, 13, 32, 96, 33, 98, 22, 7, 75, 65, 113, 118, 51, 54, 66, 99, 76, 106, 31, 127, 3, 90, 14, 49, }; static byte[] encrypt(String msg){ int len = msg.length(); char[] char1 = new char[len]; for(int i = len; i > 0; i--){ char1[len - i] = msg.charAt(i - 1); } Logger.d(TAG, "char1 length:" + char1.length); StringBuilder sb = new StringBuilder(); for(int i = 0; i < len; i++){ sb.append(table[char1[i]]); sb.append(','); } Logger.d(TAG, "sb:" + sb.toString()); return sb.toString().getBytes(); } static String decrypt(String msg){ Logger.d(TAG, "decrypt msg:" + msg); String[] msg2 = msg.split(","); char[] char1 = new char[msg2.length]; for(int i = 0; i < char1.length; i++){ char1[i] = getIdxOfTable(msg2[i]); } char[] char2 = new char[char1.length]; for(int i = char1.length; i > 0; i--){ char2[char1.length - i] = char1[i - 1]; } String msg3 = new String(char2, 0, char2.length); Logger.d(TAG, "The string that decrypt:" + msg3); return msg3; } static private char getIdxOfTable(String idx){ int num = Integer.parseInt(idx); for(int i = 0; i < table.length; i++){ if(num == table[i]){ return (char)i; } } return (char)-1; } } }