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}
|
onError={handleImageError}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
transition={300}
|
transition={300}
|
||||||
cachePolicy={"disk"}
|
cachePolicy={"memory-disk"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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
|
||||||
|
|
81
HomePage.js
81
HomePage.js
|
@ -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 {
|
||||||
|
await setAccount(accountDataInput);
|
||||||
|
await insertAccount();
|
||||||
closeEnterAccountModal();
|
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
|
||||||
|
|
101
ScanQRCode.js
101
ScanQRCode.js
|
@ -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,42 +28,79 @@ 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 = () => {
|
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();
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBarCodeScanned = ({type, data}) => {
|
const handleOtpAuth = (data) => {
|
||||||
// type org.iso.QRCode
|
const [, accountName] = data.match(/otpauth:\/\/totp\/([^?]+)/) || [];
|
||||||
// data otpauth://totp/casdoor:built-in/admin?algorithm=SHA1&digits=6&issuer=casdoor&period=30&secret=DL5XI33M772GSGU73GJPCOIBNJE7TG3J
|
const [, secretKey] = data.match(/secret=([^&]+)/) || [];
|
||||||
// console.log(`Bar code with type ${type} and data ${data} has been scanned!`);
|
const [, issuer] = data.match(/issuer=([^&]+)/) || [];
|
||||||
const accountName = data.match(/otpauth:\/\/totp\/([^?]+)/); // accountName casdoor:built-in/admin
|
|
||||||
const secretKey = data.match(/secret=([^&]+)/); // secretKey II5UO7HIA3SPVXAB6KPAIXZ33AQP7C3R
|
|
||||||
const issuer = data.match(/issuer=([^&]+)/);
|
|
||||||
if (accountName && secretKey) {
|
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 (
|
return (
|
||||||
<View style={{marginTop: "50%", flex: 1}} >
|
<View style={{marginTop: "50%", flex: 1}}>
|
||||||
<Portal>
|
<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
|
<CameraView
|
||||||
onBarcodeScanned={handleBarCodeScanned}
|
onBarcodeScanned={handleBarCodeScanned}
|
||||||
barcodeScannerSettings={{
|
barcodeScannerSettings={{
|
||||||
|
@ -69,8 +108,20 @@ const ScanQRCode = ({onClose, showScanner, onAdd}) => {
|
||||||
}}
|
}}
|
||||||
style={{flex: 1}}
|
style={{flex: 1}}
|
||||||
/>
|
/>
|
||||||
)}
|
<IconButton
|
||||||
<IconButton icon={"close"} size={40} onPress={onClose} style={{position: "absolute", top: 30, right: 5}} />
|
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>
|
||||||
);
|
);
|
||||||
|
|
13
app.json
13
app.json
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
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;
|
||||||
|
|
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/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",
|
||||||
|
|
|
@ -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;
|
||||||
|
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();
|
.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}});
|
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,
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -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