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} onError={handleImageError}
contentFit="cover" contentFit="cover"
transition={300} transition={300}
cachePolicy={"disk"} cachePolicy={"memory-disk"}
/> />
</View> </View>
); );

View File

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

View File

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

View File

@ -14,9 +14,11 @@
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {Text, View} from "react-native"; import {Text, View} from "react-native";
import {IconButton, Portal} from "react-native-paper"; import {Button, IconButton, Portal} from "react-native-paper";
import {Camera, CameraView} from "expo-camera"; import {Camera, CameraView, scanFromURLAsync} from "expo-camera";
import * as ImagePicker from "expo-image-picker";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import useProtobufDecoder from "./useProtobufDecoder";
const ScanQRCode = ({onClose, showScanner, onAdd}) => { const ScanQRCode = ({onClose, showScanner, onAdd}) => {
ScanQRCode.propTypes = { ScanQRCode.propTypes = {
@ -26,51 +28,100 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => {
}; };
const [hasPermission, setHasPermission] = useState(null); const [hasPermission, setHasPermission] = useState(null);
const decoder = useProtobufDecoder(require("./google/google_auth.proto"));
useEffect(() => { useEffect(() => {
const getCameraPermissions = async() => { const getPermissions = async() => {
const {status} = await Camera.requestCameraPermissionsAsync(); const {status: cameraStatus} = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === "granted"); setHasPermission(cameraStatus === "granted");
// const {status: mediaLibraryStatus} = await ImagePicker.requestMediaLibraryPermissionsAsync();
// setHasMediaLibraryPermission(mediaLibraryStatus === "granted");
}; };
getCameraPermissions(); getPermissions();
}, []); }, []);
const closeOptions = () => {
onClose();
};
const handleBarCodeScanned = ({type, data}) => { 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!`); // 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 supportedProtocols = ["otpauth", "otpauth-migration"];
const secretKey = data.match(/secret=([^&]+)/); // secretKey II5UO7HIA3SPVXAB6KPAIXZ33AQP7C3R const protocolMatch = data.match(new RegExp(`^(${supportedProtocols.join("|")}):`));
const issuer = data.match(/issuer=([^&]+)/); if (protocolMatch) {
if (accountName && secretKey) { const protocol = protocolMatch[1];
onAdd({accountName: accountName[1], issuer: issuer[1], secretKey: secretKey[1]}); switch (protocol) {
case "otpauth":
handleOtpAuth(data);
break;
case "otpauth-migration":
handleGoogleMigration(data);
break;
default:
return;
}
onClose();
} }
closeOptions();
}; };
const handleOtpAuth = (data) => {
const [, accountName] = data.match(/otpauth:\/\/totp\/([^?]+)/) || [];
const [, secretKey] = data.match(/secret=([^&]+)/) || [];
const [, issuer] = data.match(/issuer=([^&]+)/) || [];
if (accountName && secretKey) {
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>;
}
if (hasPermission === false) {
return <Text style={{margin: "20%"}}>No access to camera or media library</Text>;
}
return ( return (
<View style={{marginTop: "50%", flex: 1}} > <View style={{marginTop: "50%", flex: 1}}>
<Portal> <Portal>
{hasPermission === null ? ( <CameraView
<Text style={{marginLeft: "20%", marginRight: "20%"}}>Requesting for camera permission</Text> onBarcodeScanned={handleBarCodeScanned}
) : hasPermission === false ? ( barcodeScannerSettings={{
<Text style={{marginLeft: "20%", marginRight: "20%"}}>No access to camera</Text> barcodeTypes: ["qr", "pdf417"],
) : ( }}
<CameraView style={{flex: 1}}
onBarcodeScanned={handleBarCodeScanned} />
barcodeScannerSettings={{ <IconButton
barcodeTypes: ["qr", "pdf417"], icon="close"
}} size={40}
style={{flex: 1}} 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> </Portal>
</View> </View>
); );

View File

@ -40,11 +40,16 @@
[ [
"expo-camera", "expo-camera",
{ {
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera", "cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
"microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone",
"recordAudioAndroid": true
} }
] ],
[
"expo-image-picker",
{
"photosPermission": "The app accesses your photos to add Totp account."
}
],
"expo-asset"
], ],
"owner": "casdoor" "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); const config = getDefaultConfig(__dirname);
config.resolver.sourceExts.push("sql"); config.resolver.sourceExts.push("sql");
config.resolver.assetExts.push("proto");
module.exports = config; 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/bottom-tabs": "^6.5.8",
"@react-navigation/native": "^6.1.7", "@react-navigation/native": "^6.1.7",
"@shopify/flash-list": "1.6.4", "@shopify/flash-list": "1.6.4",
"buffer": "^6.0.3",
"casdoor-react-native-sdk": "1.1.0", "casdoor-react-native-sdk": "1.1.0",
"drizzle-orm": "^0.33.0", "drizzle-orm": "^0.33.0",
"eslint-plugin-import": "^2.28.1", "eslint-plugin-import": "^2.28.1",
"expo": "~51.0.26", "expo": "~51.0.31",
"expo-camera": "~15.0.14", "expo-asset": "~10.0.10",
"expo-dev-client": "~4.0.22", "expo-camera": "~15.0.15",
"expo-crypto": "~13.0.2",
"expo-dev-client": "~4.0.25",
"expo-drizzle-studio-plugin": "^0.0.2", "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-sqlite": "^14.0.6",
"expo-status-bar": "~1.12.1", "expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7", "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", "hotp-totp": "^1.0.6",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"protobufjs": "^7.4.0",
"react": "18.2.0", "react": "18.2.0",
"react-content-loader": "^7.0.2", "react-content-loader": "^7.0.2",
"react-dom": "18.2.0", "react-dom": "18.2.0",

View File

@ -14,14 +14,27 @@
import {db} from "./db/client"; import {db} from "./db/client";
import * as schema from "./db/schema"; import * as schema from "./db/schema";
import {eq} from "drizzle-orm"; import {and, eq, isNull} from "drizzle-orm";
import {create} from "zustand"; import {create} from "zustand";
import {generateToken} from "./totpUtil"; import {generateToken} from "./totpUtil";
import {syncWithCloud} from "./syncLogic"; 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) => ({ const useEditAccountStore = create((set, get) => ({
account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined, oldAccountName: undefined}, account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined, oldAccountName: undefined},
setAccount: (account) => set({account}), setAccount: (account) => {
set({account});
},
updateAccount: () => { updateAccount: () => {
const {id, accountName, issuer, secretKey, oldAccountName} = get().account; const {id, accountName, issuer, secretKey, oldAccountName} = get().account;
if (!id) {return;} if (!id) {return;}
@ -56,13 +69,133 @@ const useEditAccountStore = create((set, get) => ({
insertAccount: () => { insertAccount: () => {
const {accountName, issuer, secretKey} = get().account; const {accountName, issuer, secretKey} = get().account;
if (!accountName || !secretKey) {return;} if (!accountName || !secretKey) {return;}
db.insert(schema.accounts) const insertWithDuplicateCheck = (tx, baseAccName) => {
.values({accountName, issuer: issuer ? issuer : null, secretKey, token: generateToken(secretKey)}) let attemptCount = 0;
.run(); const maxAttempts = 10;
set({account: {id: undefined, issuer: undefined, accountName: undefined, secretKey: undefined}}); 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) => { 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, setAccount: state.setAccount,
updateAccount: state.updateAccount, updateAccount: state.updateAccount,
insertAccount: state.insertAccount, insertAccount: state.insertAccount,
insertAccounts: state.insertAccounts,
deleteAccount: state.deleteAccount, 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(); 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 => ({ export const useUpdateAccountToken = () => useUpdateAccountTokenStore(state => ({
updateToken: state.updateToken, 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;