feat: support `Google Authenticator` migration (#23)

This commit is contained in:
IZUMI-Zu 2024-08-31 21:32:44 +08:00 committed by GitHub
parent e660fbfc4b
commit 8a65256f15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1013 additions and 176 deletions

View File

@ -33,7 +33,7 @@ const AvatarWithFallback = ({source, fallbackSource, size, style}) => {
onError={handleImageError}
contentFit="cover"
transition={300}
cachePolicy={"disk"}
cachePolicy={"memory-disk"}
/>
</View>
);

View File

@ -120,12 +120,12 @@ const EnterAccountDetails = ({onClose, onAdd, validateSecret}) => {
error={!!secretError}
style={styles.input}
mode="outlined"
right={
right={(props) => (
<TextInput.Icon
icon={showPassword ? "eye-off" : "eye"}
onPress={() => setShowPassword(!showPassword)}
/>
}
)}
/>
<View style={styles.buttonContainer}>
<Menu

View File

@ -13,15 +13,13 @@
// limitations under the License.
import React, {useEffect, useRef, useState} from "react";
import {Dimensions, RefreshControl, TouchableOpacity, View} from "react-native";
import {Dimensions, InteractionManager, RefreshControl, TouchableOpacity, View} from "react-native";
import {Divider, IconButton, List, Modal, Portal, Text} from "react-native-paper";
import {GestureHandlerRootView, Swipeable} from "react-native-gesture-handler";
import {CountdownCircleTimer} from "react-native-countdown-circle-timer";
import {useNetInfo} from "@react-native-community/netinfo";
import {FlashList} from "@shopify/flash-list";
import Toast from "react-native-toast-message";
import {useLiveQuery} from "drizzle-orm/expo-sqlite";
import {isNull} from "drizzle-orm";
import SearchBar from "./SearchBar";
import EnterAccountDetails from "./EnterAccountDetails";
@ -29,10 +27,9 @@ import ScanQRCode from "./ScanQRCode";
import EditAccountDetails from "./EditAccountDetails";
import AvatarWithFallback from "./AvatarWithFallback";
import useStore from "./useStorage";
import * as schema from "./db/schema";
import {db} from "./db/client";
import {calculateCountdown, validateSecret} from "./totpUtil";
import {useAccountSync, useEditAccount, useUpdateAccountToken} from "./useAccountStore";
import {calculateCountdown} from "./totpUtil";
import {generateToken, validateSecret} from "./totpUtil";
import {useAccountStore, useAccountSync, useEditAccount} from "./useAccountStore";
const {width, height} = Dimensions.get("window");
const REFRESH_INTERVAL = 10000;
@ -44,7 +41,6 @@ export default function HomePage() {
const [showOptions, setShowOptions] = useState(false);
const [showEnterAccountModal, setShowEnterAccountModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const {data: accounts} = useLiveQuery(db.select().from(schema.accounts).where(isNull(schema.accounts.deletedAt)));
const [filteredData, setFilteredData] = useState(accounts);
const [showScanner, setShowScanner] = useState(false);
const [showEditAccountModal, setShowEditAccountModal] = useState(false);
@ -58,8 +54,12 @@ export default function HomePage() {
const swipeableRef = useRef(null);
const {userInfo, serverUrl, token} = useStore();
const {startSync} = useAccountSync();
const {updateToken} = useUpdateAccountToken();
const {setAccount, updateAccount, insertAccount, deleteAccount} = useEditAccount();
const {accounts, refreshAccounts} = useAccountStore();
const {setAccount, updateAccount, insertAccount, insertAccounts, deleteAccount} = useEditAccount();
useEffect(() => {
refreshAccounts();
}, []);
useEffect(() => {
setCanSync(Boolean(isConnected && userInfo && serverUrl));
@ -71,10 +71,15 @@ export default function HomePage() {
useEffect(() => {
const timer = setInterval(() => {
if (canSync) {startSync(userInfo, serverUrl, token);}
if (canSync) {
InteractionManager.runAfterInteractions(() => {
startSync(userInfo, serverUrl, token);
refreshAccounts();
});
}
}, REFRESH_INTERVAL);
return () => clearInterval(timer);
}, [startSync]);
}, [startSync, canSync]);
const onRefresh = async() => {
setRefreshing(true);
@ -96,15 +101,19 @@ export default function HomePage() {
});
}
}
setKey(prevKey => prevKey + 1);
refreshAccounts();
setRefreshing(false);
};
const handleAddAccount = async(accountData) => {
setKey(prevKey => prevKey + 1);
setAccount(accountData);
insertAccount();
const handleAddAccount = async(accountDataInput) => {
if (Array.isArray(accountDataInput)) {
insertAccounts(accountDataInput);
} else {
await setAccount(accountDataInput);
await insertAccount();
closeEnterAccountModal();
}
refreshAccounts();
};
const handleEditAccount = (account) => {
@ -118,12 +127,18 @@ export default function HomePage() {
if (editingAccount) {
setAccount({...editingAccount, accountName: newAccountName, oldAccountName: editingAccount.accountName});
updateAccount();
refreshAccounts();
setPlaceholder("");
setEditingAccount(null);
closeEditAccountModal();
}
};
const onAccountDelete = async(account) => {
deleteAccount(account.id);
refreshAccounts();
};
const closeEditAccountModal = () => setShowEditAccountModal(false);
const handleScanPress = () => {
@ -134,6 +149,16 @@ export default function HomePage() {
const handleCloseScanner = () => setShowScanner(false);
const handleScanError = (error) => {
setShowScanner(false);
Toast.show({
type: "error",
text1: "Scan error",
text2: error,
autoHide: true,
});
};
const togglePlusButton = () => {
setIsPlusButton(!isPlusButton);
setShowOptions(!showOptions);
@ -172,7 +197,8 @@ export default function HomePage() {
<FlashList
data={searchQuery.trim() !== "" ? filteredData : accounts}
keyExtractor={(item) => `${item.id}`}
estimatedItemSize={10}
extraData={key}
estimatedItemSize={80}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
@ -190,7 +216,7 @@ export default function HomePage() {
</TouchableOpacity>
<TouchableOpacity
style={{height: 70, width: 80, backgroundColor: "#FFC0CB", alignItems: "center", justifyContent: "center"}}
onPress={() => deleteAccount(item.id)}
onPress={() => onAccountDelete(item)}
>
<Text>Delete</Text>
</TouchableOpacity>
@ -200,22 +226,23 @@ export default function HomePage() {
<List.Item
style={{
height: 80,
paddingVertical: 5,
paddingHorizontal: 25,
justifyContent: "center",
}}
title={
<View style={{flex: 1, justifyContent: "center"}}>
<Text variant="titleMedium">{item.accountName}</Text>
<Text variant="headlineSmall" style={{fontWeight: "bold"}}>{item.token}</Text>
<View style={{justifyContent: "center", paddingLeft: 0, paddingTop: 6}}>
<Text variant="titleMedium" numberOfLines={1}>
{item.accountName}
</Text>
<Text variant="titleLarge" style={{fontWeight: "bold"}}>{generateToken(item.secretKey)}</Text>
</View>
}
left={() => (
<AvatarWithFallback
source={{uri: item.issuer ? `https://cdn.casbin.org/img/social_${item.issuer.toLowerCase()}.png` : "https://cdn.casbin.org/img/social_default.png"}}
source={{uri: `https://cdn.casbin.org/img/social_${item.issuer?.toLowerCase()}.png`}}
fallbackSource={{uri: "https://cdn.casbin.org/img/social_default.png"}}
size={60}
style={{
marginRight: 15,
borderRadius: 10,
backgroundColor: "transparent",
}}
@ -232,7 +259,7 @@ export default function HomePage() {
colorsTime={[30, 24, 18, 12, 6, 0]}
size={60}
onComplete={() => {
updateToken(item.id);
setKey(prevKey => prevKey + 1);
return {
shouldRepeat: true,
delay: 0,
@ -328,7 +355,7 @@ export default function HomePage() {
</Portal>
{showScanner && (
<ScanQRCode onClose={handleCloseScanner} showScanner={showScanner} onAdd={handleAddAccount} />
<ScanQRCode onClose={handleCloseScanner} showScanner={showScanner} onAdd={handleAddAccount} onError={handleScanError} />
)}
<TouchableOpacity

View File

@ -14,9 +14,11 @@
import React, {useEffect, useState} from "react";
import {Text, View} from "react-native";
import {IconButton, Portal} from "react-native-paper";
import {Camera, CameraView} from "expo-camera";
import {Button, IconButton, Portal} from "react-native-paper";
import {Camera, CameraView, scanFromURLAsync} from "expo-camera";
import * as ImagePicker from "expo-image-picker";
import PropTypes from "prop-types";
import useProtobufDecoder from "./useProtobufDecoder";
const ScanQRCode = ({onClose, showScanner, onAdd}) => {
ScanQRCode.propTypes = {
@ -26,42 +28,79 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => {
};
const [hasPermission, setHasPermission] = useState(null);
const decoder = useProtobufDecoder(require("./google/google_auth.proto"));
useEffect(() => {
const getCameraPermissions = async() => {
const {status} = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === "granted");
const getPermissions = async() => {
const {status: cameraStatus} = await Camera.requestCameraPermissionsAsync();
setHasPermission(cameraStatus === "granted");
// const {status: mediaLibraryStatus} = await ImagePicker.requestMediaLibraryPermissionsAsync();
// setHasMediaLibraryPermission(mediaLibraryStatus === "granted");
};
getCameraPermissions();
getPermissions();
}, []);
const closeOptions = () => {
const handleBarCodeScanned = ({type, data}) => {
// console.log(`Bar code with type ${type} and data ${data} has been scanned!`);
const supportedProtocols = ["otpauth", "otpauth-migration"];
const protocolMatch = data.match(new RegExp(`^(${supportedProtocols.join("|")}):`));
if (protocolMatch) {
const protocol = protocolMatch[1];
switch (protocol) {
case "otpauth":
handleOtpAuth(data);
break;
case "otpauth-migration":
handleGoogleMigration(data);
break;
default:
return;
}
onClose();
}
};
const handleBarCodeScanned = ({type, data}) => {
// type org.iso.QRCode
// data otpauth://totp/casdoor:built-in/admin?algorithm=SHA1&digits=6&issuer=casdoor&period=30&secret=DL5XI33M772GSGU73GJPCOIBNJE7TG3J
// console.log(`Bar code with type ${type} and data ${data} has been scanned!`);
const accountName = data.match(/otpauth:\/\/totp\/([^?]+)/); // accountName casdoor:built-in/admin
const secretKey = data.match(/secret=([^&]+)/); // secretKey II5UO7HIA3SPVXAB6KPAIXZ33AQP7C3R
const issuer = data.match(/issuer=([^&]+)/);
const handleOtpAuth = (data) => {
const [, accountName] = data.match(/otpauth:\/\/totp\/([^?]+)/) || [];
const [, secretKey] = data.match(/secret=([^&]+)/) || [];
const [, issuer] = data.match(/issuer=([^&]+)/) || [];
if (accountName && secretKey) {
onAdd({accountName: accountName[1], issuer: issuer[1], secretKey: secretKey[1]});
onAdd({accountName, issuer: issuer || null, secretKey});
}
};
const handleGoogleMigration = (data) => {
const accounts = decoder.decodeExportUri(data);
onAdd(accounts.map(({accountName, issuer, totpSecret}) => ({accountName, issuer, secretKey: totpSecret})));
};
const pickImage = async() => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 1,
});
if (!result.canceled && result.assets[0]) {
const scannedData = await scanFromURLAsync(result.assets[0].uri, ["qr", "pdf417"]);
if (scannedData[0]) {
handleBarCodeScanned({type: scannedData[0].type, data: scannedData[0].data});
}
}
};
if (hasPermission === null) {
return <Text style={{margin: "20%"}}>Requesting permissions...</Text>;
}
closeOptions();
};
if (hasPermission === false) {
return <Text style={{margin: "20%"}}>No access to camera or media library</Text>;
}
return (
<View style={{marginTop: "50%", flex: 1}} >
<View style={{marginTop: "50%", flex: 1}}>
<Portal>
{hasPermission === null ? (
<Text style={{marginLeft: "20%", marginRight: "20%"}}>Requesting for camera permission</Text>
) : hasPermission === false ? (
<Text style={{marginLeft: "20%", marginRight: "20%"}}>No access to camera</Text>
) : (
<CameraView
onBarcodeScanned={handleBarCodeScanned}
barcodeScannerSettings={{
@ -69,8 +108,20 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => {
}}
style={{flex: 1}}
/>
)}
<IconButton icon={"close"} size={40} onPress={onClose} style={{position: "absolute", top: 30, right: 5}} />
<IconButton
icon="close"
size={40}
onPress={onClose}
style={{position: "absolute", top: 30, right: 5}}
/>
<Button
icon="image"
mode="contained"
onPress={pickImage}
style={{position: "absolute", bottom: 20, alignSelf: "center"}}
>
Choose Image
</Button>
</Portal>
</View>
);

View File

@ -40,11 +40,16 @@
[
"expo-camera",
{
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera",
"microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone",
"recordAudioAndroid": true
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
}
]
],
[
"expo-image-picker",
{
"photosPermission": "The app accesses your photos to add Totp account."
}
],
"expo-asset"
],
"owner": "casdoor"
}

43
google/google_auth.proto Normal file
View File

@ -0,0 +1,43 @@
syntax = "proto3";
package google_auth;
message MigrationPayload {
enum Algorithm {
ALGORITHM_UNSPECIFIED = 0;
SHA1 = 1;
SHA256 = 2;
SHA512 = 3;
MD5 = 4;
}
enum DigitCount {
DIGIT_COUNT_UNSPECIFIED = 0;
SIX = 1;
EIGHT = 2;
SEVEN = 3;
}
enum OtpType {
OTP_TYPE_UNSPECIFIED = 0;
HOTP = 1;
TOTP = 2;
}
message OtpParameters {
bytes secret = 1;
string name = 2;
string issuer = 3;
Algorithm algorithm = 4;
DigitCount digits = 5;
OtpType type = 6;
int64 counter = 7;
string unique_id = 8;
}
repeated OtpParameters otp_parameters = 1;
int32 version = 2;
int32 batch_size = 3;
int32 batch_index = 4;
int32 batch_id = 5;
}

View File

@ -4,5 +4,6 @@ const {getDefaultConfig} = require("expo/metro-config");
const config = getDefaultConfig(__dirname);
config.resolver.sourceExts.push("sql");
config.resolver.assetExts.push("proto");
module.exports = config;

673
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,20 +16,26 @@
"@react-navigation/bottom-tabs": "^6.5.8",
"@react-navigation/native": "^6.1.7",
"@shopify/flash-list": "1.6.4",
"buffer": "^6.0.3",
"casdoor-react-native-sdk": "1.1.0",
"drizzle-orm": "^0.33.0",
"eslint-plugin-import": "^2.28.1",
"expo": "~51.0.26",
"expo-camera": "~15.0.14",
"expo-dev-client": "~4.0.22",
"expo": "~51.0.31",
"expo-asset": "~10.0.10",
"expo-camera": "~15.0.15",
"expo-crypto": "~13.0.2",
"expo-dev-client": "~4.0.25",
"expo-drizzle-studio-plugin": "^0.0.2",
"expo-image": "^1.12.13",
"expo-image": "~1.12.15",
"expo-image-picker": "~15.0.7",
"expo-sqlite": "^14.0.6",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.22",
"expo-updates": "~0.25.24",
"hi-base32": "^0.5.1",
"hotp-totp": "^1.0.6",
"prop-types": "^15.8.1",
"protobufjs": "^7.4.0",
"react": "18.2.0",
"react-content-loader": "^7.0.2",
"react-dom": "18.2.0",

View File

@ -14,14 +14,27 @@
import {db} from "./db/client";
import * as schema from "./db/schema";
import {eq} from "drizzle-orm";
import {and, eq, isNull} from "drizzle-orm";
import {create} from "zustand";
import {generateToken} from "./totpUtil";
import {syncWithCloud} from "./syncLogic";
export const useAccountStore = create((set, get) => ({
accounts: [],
refreshAccounts: () => {
const accounts = db.select().from(schema.accounts).where(isNull(schema.accounts.deletedAt)).all();
set({accounts});
},
setAccounts: (accounts) => {
set({accounts});
},
}));
const useEditAccountStore = create((set, get) => ({
account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined, oldAccountName: undefined},
setAccount: (account) => set({account}),
setAccount: (account) => {
set({account});
},
updateAccount: () => {
const {id, accountName, issuer, secretKey, oldAccountName} = get().account;
if (!id) {return;}
@ -56,13 +69,133 @@ const useEditAccountStore = create((set, get) => ({
insertAccount: () => {
const {accountName, issuer, secretKey} = get().account;
if (!accountName || !secretKey) {return;}
db.insert(schema.accounts)
.values({accountName, issuer: issuer ? issuer : null, secretKey, token: generateToken(secretKey)})
const insertWithDuplicateCheck = (tx, baseAccName) => {
let attemptCount = 0;
const maxAttempts = 10;
const tryInsert = (accName) => {
const existingAccount = tx.select()
.from(schema.accounts)
.where(and(
eq(schema.accounts.accountName, accName),
eq(schema.accounts.issuer, issuer || null),
eq(schema.accounts.secretKey, secretKey)
))
.get();
if (existingAccount) {
return accName;
}
const conflictingAccount = tx.select()
.from(schema.accounts)
.where(and(
eq(schema.accounts.accountName, accName),
eq(schema.accounts.issuer, issuer || null)
))
.get();
if (conflictingAccount) {
if (attemptCount >= maxAttempts) {
throw new Error(`Cannot generate a unique name for account ${baseAccName}, tried ${maxAttempts} times`);
}
attemptCount++;
const newAccountName = `${baseAccName}_${Math.random().toString(36).slice(2, 5)}`;
return tryInsert(newAccountName);
}
tx.insert(schema.accounts)
.values({
accountName: accName,
issuer: issuer || null,
secretKey,
token: generateToken(secretKey),
})
.run();
return accName;
};
return tryInsert(baseAccName);
};
try {
const finalAccountName = db.transaction((tx) => {
return insertWithDuplicateCheck(tx, accountName);
});
set({account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined}});
return finalAccountName;
} catch (error) {
return null;
}
},
insertAccounts: (accounts) => {
try {
db.transaction((tx) => {
const insertWithDuplicateCheck = (baseAccName, issuer, secretKey) => {
let attemptCount = 0;
const maxAttempts = 10;
const tryInsert = (accName) => {
const existingAccount = tx.select()
.from(schema.accounts)
.where(and(
eq(schema.accounts.accountName, accName),
eq(schema.accounts.issuer, issuer || null),
eq(schema.accounts.secretKey, secretKey)
))
.get();
if (existingAccount) {
return accName;
}
const conflictingAccount = tx.select()
.from(schema.accounts)
.where(and(
eq(schema.accounts.accountName, accName),
eq(schema.accounts.issuer, issuer || null)
))
.get();
if (conflictingAccount) {
if (attemptCount >= maxAttempts) {
throw new Error(`Cannot generate a unique name for account ${baseAccName}, tried ${maxAttempts} times`);
}
attemptCount++;
const newAccountName = `${baseAccName}_${Math.random().toString(36).slice(2, 7)}`;
return tryInsert(newAccountName);
}
tx.insert(schema.accounts)
.values({
accountName: accName,
issuer: issuer || null,
secretKey,
token: generateToken(secretKey),
})
.run();
return accName;
};
return tryInsert(baseAccName);
};
for (const account of accounts) {
const {accountName, issuer, secretKey} = account;
if (!accountName || !secretKey) {continue;}
insertWithDuplicateCheck(accountName, issuer, secretKey);
}
});
} catch (error) {
return null;
}
},
deleteAccount: async(id) => {
db.update(schema.accounts).set({deletedAt: new Date()}).where(eq(schema.accounts.id, id)).run();
db.update(schema.accounts)
.set({deletedAt: new Date()})
.where(eq(schema.accounts.id, id))
.run();
},
}));
@ -71,6 +204,7 @@ export const useEditAccount = () => useEditAccountStore(state => ({
setAccount: state.setAccount,
updateAccount: state.updateAccount,
insertAccount: state.insertAccount,
insertAccounts: state.insertAccounts,
deleteAccount: state.deleteAccount,
}));
@ -108,8 +242,17 @@ const useUpdateAccountTokenStore = create(() => ({
db.update(schema.accounts).set({token: generateToken(account.secretKey)}).where(eq(schema.accounts.id, id)).run();
}
},
updateAllTokens: async() => {
db.transaction(async(tx) => {
const accounts = tx.select().from(schema.accounts).where(isNull(schema.accounts.deletedAt)).all();
for (const account of accounts) {
tx.update(schema.accounts).set({token: generateToken(account.secretKey)}).where(eq(schema.accounts.id, account.id)).run();
}
});
},
}));
export const useUpdateAccountToken = () => useUpdateAccountTokenStore(state => ({
updateToken: state.updateToken,
updateAllTokens: state.updateAllTokens,
}));

78
useProtobufDecoder.js Normal file
View File

@ -0,0 +1,78 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {useEffect, useState} from "react";
import {useAssets} from "expo-asset";
import * as FileSystem from "expo-file-system";
import protobuf from "protobufjs";
import {encode as base32Encode} from "hi-base32";
import {Buffer} from "buffer";
const useProtobufDecoder = (protobufAsset) => {
const [assets] = useAssets([protobufAsset]);
const [decoder, setDecoder] = useState(null);
useEffect(() => {
const initializeDecoder = async() => {
if (!assets) {return;}
try {
// Read the file content
const fileContent = await FileSystem.readAsStringAsync(assets[0].localUri);
// Parse the protobuf schema
const root = protobuf.parse(fileContent).root;
const MigrationPayload = root.lookupType("google_auth.MigrationPayload");
// Create the decoder object
const newDecoder = {
decodeProtobuf: (payload) => {
const message = MigrationPayload.decode(payload);
return MigrationPayload.toObject(message, {
longs: String,
enums: String,
bytes: String,
});
},
decodeData: (data) => {
const buffer = Buffer.from(decodeURIComponent(data), "base64");
const payload = newDecoder.decodeProtobuf(buffer);
return payload.otpParameters.map(account => ({
accountName: account.name,
issuer: account.issuer || null,
totpSecret: base32Encode(Buffer.from(account.secret, "base64")),
}));
},
decodeExportUri: (uri) => {
const data = new URL(uri).searchParams.get("data");
if (!data) {
throw new Error("No data parameter found in the URI");
}
return newDecoder.decodeData(data);
},
};
setDecoder(newDecoder);
} catch (error) {
throw new Error("Failed to initialize ProtobufDecoder:", error);
}
};
initializeDecoder();
}, [assets]);
return decoder;
};
export default useProtobufDecoder;