google-authenticator-flutter
谷歌身份驗證器Flutter版
Table of Contents
介紹谷歌驗證器
最近公司使用專案管理gitlab的時候,使用到了兩步驗證器。所以藉著這個機會熟悉一下flutter,就以谷歌身份驗證器這個專案來進行實踐。
步驟實現
以具體的步驟來分析完成。
攝像頭的幀資料
自定義掃碼介面
- 自定義掃描中間控制元件
canvas.drawRect(Offset.zero & size, rPaint);
canvas.drawLine(Offset(0, 2), Offset(20, 2), paint);
canvas.drawLine(Offset(2, 0), Offset(2, 20), paint);
canvas.drawLine(Offset(size.width, 2), Offset(size.width - 20, 2), paint);
canvas.drawLine(
Offset(size.width - 2, 0), Offset(size.width - 2, 20), paint);
canvas.drawLine(
Offset(0, size.height - 2), Offset(20, size.height - 2), paint);
canvas.drawLine(Offset(2, size.height), Offset(2, size.height - 20), paint);
canvas.drawLine(Offset(size.width - 2, size.height),
Offset(size.width - 2, size.height - 20), paint);
canvas.drawLine(Offset(size.width, size.height - 2),
Offset(size.width - 20, size.height - 2), paint);
複製程式碼
- 動畫展示
controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
animation = Tween(begin: 0.0, end: 200.0).animate(controller)
..addListener(() {
setState(() => {});
})
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reset();
} else if (status == AnimationStatus.dismissed) {
controller.forward();
}
});
controller.forward();
複製程式碼
- 使用
Align
佈局新增自定義控制元件
Align(
alignment: FractionalOffset.center,
child: ScanView(),
),
複製程式碼
- 使用
Positioned
佈局新增中心四周陰影效果
獲取二維碼資訊
將幀資料傳給端進行解析。以Android為例。
- flutter層進行資料傳送與接收
static const MethodChannel _channel =
const MethodChannel('fairy.e.validator/qrcode');
static Future<String> loadImageBytes({
List<Uint8List> bytes,
int imageWidth = 720,
int imageHeight = 1280,
}) async {
return await _channel.invokeMethod(
'imageStream',
{
"cameraBytes": bytes,
"width": imageWidth,
"height": imageHeight,
},
);
}
複製程式碼
- Android層進行資料接收與傳送
MethodChannel channel = new MethodChannel(getFlutterView(), QR_CODE_CHANNEL);
channel.setMethodCallHandler(
new MethodChannel.MethodCallHandler() {
@Override
public void onMethodCall(MethodCall call,MethodChannel.Result result) {
switch (call.method) {
case QR_CODE_BYTES:
runOnFrame((HashMap) call.arguments, result);
break;
default:
result.notImplemented();
break;
}
}
});
複製程式碼
- 楨資料轉換
List<byte[]> bytesList = (List<byte[]>) args.get("cameraBytes");
ByteBuffer Y = ByteBuffer.wrap(bytesList.get(0));
ByteBuffer U = ByteBuffer.wrap(bytesList.get(1));
ByteBuffer V = ByteBuffer.wrap(bytesList.get(2));
int Yb = Y.remaining();
int Ub = U.remaining();
int Vb = V.remaining();
byte[] data = new byte[Yb + Ub + Vb];
Y.get(data, 0, Yb);
V.get(data, Yb, Vb);
U.get(data, Yb + Vb, Ub);
複製程式碼
- 匯入Zxing庫進行解析
Result rawResult = null;
PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource(data, width, height, 0, 0,
width, height);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
try {
rawResult = multiFormatReader.decodeWithState(bitmap);
} catch (ReaderException e) {
e.printStackTrace();
} finally {
multiFormatReader.reset();
}
複製程式碼
獲取資料並儲存
- 依次解析二維碼資訊
Uri uri = Uri.parse(result);
String period =
isTotp ? uri.queryParameters['period'] : uri.queryParameters['counter'];
if (null == period) {
period = isTotp ? '30' : '1';
} else if (!isTotp) {
period = (int.parse(period) + 1).toString();
}
String secret = uri.queryParameters["secret"];
if (null == secret) {
ToastHelper.showToast(context, 'QR碼非法');
return;
}
String path = uri.path;
if (null == path) {
ToastHelper.showToast(context, 'QR碼非法');
return;
}
path = path.substring(1);
String issuer = uri.queryParameters["issuer"];
複製程式碼
- 資料庫儲存
使用開源儲存庫sqflite進行資料儲存
獲取校驗值
- 演算法獲取
Uint8List keys = base32.decode(secret);
var hmacSha1 = new Hmac(sha1, keys);
int time = isTotp ? (currentTimeMillis() / 1000) ~/ 30: int.parse(counter);
Uint8List data = new Uint8List(8);
int value = time;
for (int i = 8; i-- > 0; value >>= 8) {
data[i] = value;
}
Uint8List digest = hmacSha1.convert(data).bytes;
int offset = digest[20 - 1] & 0xF;
int truncatedHash = 0;
for (int i = 0; i < 4; ++i) {
truncatedHash <<= 8;
truncatedHash |= (digest[offset + i] & 0xFF);
}
truncatedHash &= 0x7FFFFFFF;
truncatedHash %= 1000000;
print("結果為${truncatedHash.toString()}");
複製程式碼
- 值填充
static String getNumberHash(String number){
for(int i=number.length;i<6;i++){
number="0"+number;
}
return number;
}
複製程式碼
頁面展示
- 以
ListTile
控制元件展示
ListTile(
contentPadding:
EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
trailing: data.isTotp ? _totpIcon() : _hotpIcon(data.id),
title: Text(
Utils.getNumber(data.secret, data.isTotp, data.period),
style: styleNumber,
),
subtitle: Text(
Utils.getPath(data.path, data.issuer),
style: styleSubtitle,
),
onTap: () => {_hotpTap(data)},
onLongPress: () => {_show(data)},
),
複製程式碼
- 基於時間動畫效果
double start = 2 * pi * ((30 - time) / 30);
controller =
AnimationController(duration: Duration(seconds: time), vsync: this);
animation = Tween(begin: start, end: 2 * pi).animate(controller)
..addListener(() {
setState(() => {});
})
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
viewListener.onSuccess();
controllerTotp = AnimationController(
duration: const Duration(seconds: 30), vsync: this);
animation = Tween(begin: 0.0, end: 2 * pi).animate(controllerTotp)
..addListener(() {
setState(() => {});
})
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
controllerTotp.reset();
viewListener.onSuccess();
} else if (status == AnimationStatus.dismissed) {
controllerTotp.forward();
}
});
controllerTotp.forward();
} else if (status == AnimationStatus.dismissed) {
controller.forward();
}
});
controller.forward();
複製程式碼
- 基於計數器動畫效果
以Map儲存相關id與Timer。實現點選單個響應單個的效果。
if (!valueMap.containsKey(data.id) && !data.isTotp) {
startCountdownTimer(data.id);
}
void startCountdownTimer(id) {
Timer timer = Timer(new Duration(seconds: 5), () {
print("倒數計時完成");
setState(() {
valueMap.remove(id);
});
});
setState(() {
valueMap[id] = timer;
});
}
複製程式碼
實現效果
掃碼體驗
亟待解決
ios的資料解析,還有待實現。