feat: support `Google Authenticator` migration (#23)
This commit is contained in:
parent
e660fbfc4b
commit
8a65256f15
|
@ -33,7 +33,7 @@ const AvatarWithFallback = ({source, fallbackSource, size, style}) => {
|
|||
onError={handleImageError}
|
||||
contentFit="cover"
|
||||
transition={300}
|
||||
cachePolicy={"disk"}
|
||||
cachePolicy={"memory-disk"}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
81
HomePage.js
81
HomePage.js
|
@ -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
|
||||
|
|
101
ScanQRCode.js
101
ScanQRCode.js
|
@ -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>
|
||||
);
|
||||
|
|
13
app.json
13
app.json
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
@ -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;
|
Loading…
Reference in New Issue