智能门锁临时密码的简单实现~~
引子
话说新房子装修,安装了遥遥领先智能门锁PRO,最近到了家具进场的阶段。
某日,接到一通电话:“哥,你现在家里有人吗?你的书桌到了。”
原来是快递小哥,我回复他:“家里没人,但是有智能锁,嗯,因为临时密码有时间限制,等下到了再给我回下电话,我把临时密码给你。”
“好嘞,那到时候联系”
挂断电话,我随手打开手机上的花粉生活APP,但是感觉有点不对劲,我去,设备咋都离线了(后来发现是网络欠费)?我顿时虎躯一震,脑海中浮现了快递小哥到了后发现自己白跑一趟,带着满头大汗、气喘吁吁并且嘴里一顿C语言输出的尴尬场景...
但是我惊喜的发现,门锁的卡片虽然离线但还可以正常进入,我抱着试一试的心态点进去,临时密码竟然可以正常生成,真牛!
于是我点击了生成临时密码...
电话又响起:“哥我到了,把密码给我吧”
我将临时密码给小哥开了门,一切顺利...
实现
这是前段时间亲身经历的一件事,原本以为智能门锁临时密码的功能需要网络支持,服务器生成临时密码给用户,同时下发到门锁里面。现在发现,并不需要门锁联网也可以执行密码验证的操作。
脑海中思考了下,临时密码离线验证这个功能可能是类似这样实现的:
- 门锁端和服务器端采用相同的规则生成临时密码,并且密码生成规则里面包含了时间这个因素
- 用户请求临时密码,服务端按照规则生成临时密码返回给用户
- 用户输入临时密码解锁,门锁按照同样的规则进行校验
以上实现是一个直觉性的思考,实际编码落地根据不同的需求会有更多的考虑,以我在使用的遥遥领先牌智能门锁Pro为例,下面来做一个简单的实现...
首先,让来看看这款门锁的临时密码有哪些限制条件:
限制条件有:
- 单个密码有效期为30分钟
- 有效期内只能使用一次
- 一分钟内只能添加一个临时密码
根据这些限制条件和前面的思考,密码生成规则可以这样设置:
- 拼接产品序列号+当前时间字符串,获取拼接后字符串的hashcode,然后对1000000(百万)取余,得到6位数字作为临时密码。并且时间字符串按照yyyy-MM-dd HH:mm 格式,精确到分钟
- 加入产品序列号的原因是为了让不同门锁在相同时间产生不同的密码,如果只以时间为变量肯定是不安全的
- 由于门锁生成的限制条件里面约定了一分钟只能添加一个临时密码,因此时间变量也精确到分钟,保证每分钟的临时密码不同,分钟内相同。
然后是实现思路:
- 用户请求服务端,服务端根据密码生成规则返回一个临时密码
- 快递小哥拿着临时密码在门锁现场输入
- 门锁按照临时密码输入的时间点,计算时间点前30分内每一分钟对应的密码,30分钟对应30个临时密码。为什么是30分钟?因为密码30分钟内有效
- 门锁将快递小哥输入的密码与生成的30个密码进行一一比对,如果有匹配的密码,说明临时密码有效
- 将输入的临时密码缓存,每次输入密码时都要去缓存里面判断临时密码是否在30分钟内使用过,如果使用过就不能开锁。为什么要判断是否30分钟内使用过?因为有效期内只能使用一次
有了以上思路,下面代码的编写工作就比较简单了,开整...
首先创建三个类:OtherTerminal、SmartLock、PasswordUtils 分别,表示其他可获取密码的终端、门锁以及跟密码相关的工具类
首先是OtherTerminal类,相当于可获取密码的终端,例如我们的手机或者平板,主要功能是调用PasswordUtils工具类根据门锁的序列号和当前时间来获取有效临时密码。
public class OtherTerminal {
private final static String serialNumber = "XiaoHuaSmartLock001";
public static void main(String[] args) {
System.out.println("当前开锁密码:"+PasswordUtils.generate(serialNumber, PasswordUtils.localDateTimeToStr(LocalDateTime.now())));
}
}
接着是SmartLock类
SmartLock的main方法里面等待控制台的输入,并对输入的密码进行验证。验证调用了verify方法。
verify方法的执行逻辑:调用PasswordUtils工具类,获取过去30分钟内每分钟对应的临时密码,判断输入的密码是否在这些临时密码当中。如果存在说明临时密码有效,还需对当前密码在过去30分钟内是否使用进行判断,保证密码只能使用一次。这个判断是通过调用PasswordUtils工具类的getAndSet方法实现的。
如果认证成功,则开锁。否则开锁失败。
// 智能门锁
public class SmartLock {
private final static String serialNumber = "XiaoHuaSmartLock001";
private final static Integer expirationTime = 30;
public static void main(String[] args) {
// 步骤:首先生成过去30分钟内的所有数字
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
int password = scanner.nextInt();
if (verify(password)) {
System.out.println("开锁成功,当前时间:" + LocalDateTime.now());
} else {
System.out.println("开锁失败,当前时间:" + LocalDateTime.now());
}
}
scanner.close();
}
private static boolean verify(Integer inputPassword) {
// 获取当前时间点以前30分钟内的所有密码
LocalDateTime now = LocalDateTime.now();
LocalDateTime validityPeriod = now.minusMinutes(expirationTime);
List<Integer> validityPeriodPasswords = new ArrayList<>();
while (validityPeriod.isBefore(now.plusMinutes(1L))) {
validityPeriodPasswords.add(PasswordUtils.generate(serialNumber, PasswordUtils.localDateTimeToStr(validityPeriod)));
validityPeriod = validityPeriod.plusMinutes(1L);
}
System.out.println(validityPeriodPasswords);
return validityPeriodPasswords.contains(inputPassword) && PasswordUtils.getAndSet(inputPassword);
}
}
再来看下PasswordUtils工具类,这个类内容较多,分步解释:
首先是生成6位临时密码的generate方法,比较简单。但是这样生成的密码不能以0开头,是缺点!
/**
* 生成一个密码
*
* @return 返回一个六位正整数
*/
public static Integer generate(String serialNumber, String time) {
String toHash = time + serialNumber;
return Math.abs(toHash.hashCode() % 1000000);
}
接着是一个格式化时间的方法,将时间格式化为:yyyy-MM-dd HH:mm。精确到分钟,generate方法的第二个参数time需要调用此方法来保证时间以分钟为单位,这样分钟内生成的密码都是相同的
public static String localDateTimeToStr(LocalDateTime localDateTime) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
return formatter.format(localDateTime);
}
最后是门锁对临时密码的管理:
- 临时密码存储在一个map对象中:usedPasswordMap
- 有一个标记对象clearTag用于标记是否应当对usedPasswordMap进行清理操作,用于清理已过期的临时密码
- 临时密码存在时间大于30分钟,判断为已过期
下面是临时密码过期判断和过期清理的方法
/**
* @param current 当前时间
* @param compare 比较时间
* @return 是否过期
*/
private static boolean expired(long current, long compare) {
Instant endInstant = Instant.ofEpochMilli(current);
LocalDateTime end = LocalDateTime.ofInstant(endInstant, ZoneId.systemDefault());
Instant beginInstant = Instant.ofEpochMilli(compare);
LocalDateTime begin = LocalDateTime.ofInstant(beginInstant, ZoneId.systemDefault());
Duration duration = Duration.between(begin, end);
long actualInterval = switch (PasswordUtils.expirationUnit) {
case SECONDS -> duration.toSeconds();
case MINUTES -> duration.toMinutes();
case HOURS -> duration.toHours();
case DAYS -> duration.toDays();
default -> throw new IllegalArgumentException("输入时间类型不支持");
};
return actualInterval >= (long) PasswordUtils.expirationTime;
}
/**
* 清理过期的密码
*/
private static void clearExpired() {
Iterator<Map.Entry<Integer, Long>> iterator = usedPasswordMap.entrySet().iterator();
Long currentTimestamp = System.currentTimeMillis();
while (iterator.hasNext()) {
Map.Entry<Integer, Long> item = iterator.next();
if (expired(currentTimestamp, item.getValue())) {
iterator.remove();
}
}
}
getAndSet方法:
- 首先判断是否达到了清理阈值,从而执行是否清理的操作,用于节省资源消耗
- 从usedPasswordMap中获取当前输入密码是否存在,如果不存在说明密码未使用过,则将当前密码设置到map里面并返回true,否则还要进行进一步的判断,因为可能存在历史密码但是已过期和当前密码重复的情况
- 若usedPasswordMap中存在当前密码,调用expired方法,如果历史密码过期了说明当前密码有效,并刷新时间戳,否则说明有效期内当前密码已经使用过一次
/**
*
* @param password
* @return false说明密码已经使用过,true则表示密码可以使用
*/
public static boolean getAndSet(Integer password) {
// usedPasswordMap存储的过期密码可能会越来越多,需要定期清理
if (clearTag > clearThreshold) {
if (!usedPasswordMap.isEmpty()) {
clearExpired();
}
clearTag = 0;
}
clearTag++;
Long usedPasswordTimestamp = usedPasswordMap.get(password);
Long currentTimestamp = System.currentTimeMillis();
if (ObjectUtils.isEmpty(usedPasswordTimestamp)) {
usedPasswordMap.put(password, currentTimestamp);
return true;
}
// 到了这里说明密码已经使用过(有效期内,或之前),若使用时间距今在有效期内,说明当期已经使用过,否则是以前使用的
if (expired(currentTimestamp, usedPasswordTimestamp)) {
usedPasswordMap.put(password, currentTimestamp);
System.out.println("密码虽然已使用,但为历史使用,因此当前密码有效");
return true;
}
System.out.println("密码有效期内已使用一次");
return false;
}
验证
我将门锁程序部署到我的服务器上面,并运行。随便输入一个数字,例如123456,返回开锁失败。
然后本地运行OtherTerminal类获取临时密码:974971
再去门锁上验证试试:开锁成功!
最后完整的PasswordUtil工具类的代码贴在这里:
// 密码工具类
public class PasswordUtils {
private static Map<Integer, Long> usedPasswordMap = new HashMap<>();
private final static Integer expirationTime = 30;
private final static TimeUnit expirationUnit = TimeUnit.MINUTES;
private final static Integer clearThreshold = 30;
private static Integer clearTag = 0;
/**
* 获取code状态,并设置到使用code里面
*
* @param password
* @return false说明密码已经使用过,true则表示密码可以使用
*/
public static boolean getAndSet(Integer password) {
// usedPasswordMap存储的过期密码可能会越来越多,需要定期清理
if (clearTag > clearThreshold) {
if (!usedPasswordMap.isEmpty()) {
clearExpired();
}
clearTag = 0;
}
clearTag++;
Long usedPasswordTimestamp = usedPasswordMap.get(password);
Long currentTimestamp = System.currentTimeMillis();
if (ObjectUtils.isEmpty(usedPasswordTimestamp)) {
usedPasswordMap.put(password, currentTimestamp);
return true;
}
// 到了这里说明密码已经使用过(有效期内,或之前),若使用时间距今在有效期内,说明当期已经使用过,否则是以前使用的
if (expired(currentTimestamp, usedPasswordTimestamp)) {
usedPasswordMap.put(password, currentTimestamp);
System.out.println("密码虽然已使用,但为历史使用,因此当前密码有效");
return true;
}
System.out.println("密码有效期内已使用一次");
return false;
}
/**
* @param current 当前时间
* @param compare 比较时间
* @return 是否过期
*/
private static boolean expired(long current, long compare) {
Instant endInstant = Instant.ofEpochMilli(current);
LocalDateTime end = LocalDateTime.ofInstant(endInstant, ZoneId.systemDefault());
Instant beginInstant = Instant.ofEpochMilli(compare);
LocalDateTime begin = LocalDateTime.ofInstant(beginInstant, ZoneId.systemDefault());
Duration duration = Duration.between(begin, end);
long actualInterval;
switch (PasswordUtils.expirationUnit) {
case SECONDS:
actualInterval = duration.toSeconds();
break;
case MINUTES:
actualInterval = duration.toMinutes();
break;
case HOURS:
actualInterval = duration.toHours();
break;
case DAYS:
actualInterval = duration.toDays();
break;
default:
throw new IllegalArgumentException("输入时间类型不支持");
}
return actualInterval >= (long) PasswordUtils.expirationTime;
}
/**
* 清理过期的密码
*/
private static void clearExpired() {
Iterator<Map.Entry<Integer, Long>> iterator = usedPasswordMap.entrySet().iterator();
Long currentTimestamp = System.currentTimeMillis();
while (iterator.hasNext()) {
Map.Entry<Integer, Long> item = iterator.next();
if (expired(currentTimestamp, item.getValue())) {
iterator.remove();
}
}
}
/**
* 生成一个密码
*
* @return 返回一个六位正整数
*/
public static Integer generate(String serialNumber, String time) {
String toHash = time + serialNumber;
return Math.abs(toHash.hashCode() % 1000000);
}
public static String localDateTimeToStr(LocalDateTime localDateTime) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
return formatter.format(localDateTime);
}
}
最后的最后,这种方法生成的密码有个bug,就是30分钟内生成的30个密码里面会有重复的可能性,不过想来发生概率很低,看后续如何优化了。
来源:juejin.cn/post/7280459667129188387