Skip to content

Commit

Permalink
feat: support Google Authenticator migration (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
IZUMI-Zu authored Aug 31, 2024
1 parent e660fbf commit 8a65256
Show file tree
Hide file tree
Showing 11 changed files with 1,010 additions and 173 deletions.
2 changes: 1 addition & 1 deletion AvatarWithFallback.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const AvatarWithFallback = ({source, fallbackSource, size, style}) => {
onError={handleImageError}
contentFit="cover"
transition={300}
cachePolicy={"disk"}
cachePolicy={"memory-disk"}
/>
</View>
);
Expand Down
4 changes: 2 additions & 2 deletions EnterAccountDetails.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 55 additions & 28 deletions HomePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,23 @@
// 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";
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;
Expand All @@ -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);
Expand All @@ -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));
Expand All @@ -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);
Expand All @@ -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();
closeEnterAccountModal();
const handleAddAccount = async(accountDataInput) => {
if (Array.isArray(accountDataInput)) {
insertAccounts(accountDataInput);
} else {
await setAccount(accountDataInput);
await insertAccount();
closeEnterAccountModal();
}
refreshAccounts();
};

const handleEditAccount = (account) => {
Expand All @@ -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 = () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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} />
}
Expand All @@ -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>
Expand All @@ -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",
}}
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
115 changes: 83 additions & 32 deletions ScanQRCode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -26,51 +28,100 @@ 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 = () => {
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 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 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});
}
};

closeOptions();
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 (
<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={{
barcodeTypes: ["qr", "pdf417"],
}}
style={{flex: 1}}
/>
)}
<IconButton icon={"close"} size={40} onPress={onClose} style={{position: "absolute", top: 30, right: 5}} />
<CameraView
onBarcodeScanned={handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr", "pdf417"],
}}
style={{flex: 1}}
/>
<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>
);
Expand Down
13 changes: 9 additions & 4 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Loading

0 comments on commit 8a65256

Please sign in to comment.