feat: support importing entried from other authenticators (#30)

This commit is contained in:
IZUMI-Zu 2024-10-10 23:10:27 +08:00 committed by GitHub
parent e3f7ab5203
commit 308b546395
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 5435 additions and 3334 deletions

View File

@ -26,6 +26,7 @@ import EnterAccountDetails from "./EnterAccountDetails";
import ScanQRCode from "./ScanQRCode";
import EditAccountDetails from "./EditAccountDetails";
import AvatarWithFallback from "./AvatarWithFallback";
import {useImportManager} from "./ImportManager";
import useStore from "./useStorage";
import {calculateCountdown} from "./totpUtil";
import {generateToken, validateSecret} from "./totpUtil";
@ -57,6 +58,16 @@ export default function HomePage() {
const {setAccount, updateAccount, insertAccount, insertAccounts, deleteAccount} = useEditAccount();
const {notify} = useNotifications();
const {showImportOptions} = useImportManager((data) => {
handleAddAccount(data);
}, (err) => {
notify("error", {
params: {title: "Import error", description: err.message},
});
}, () => {
setShowScanner(true);
});
useEffect(() => {
refreshAccounts();
}, []);
@ -107,7 +118,7 @@ export default function HomePage() {
const handleAddAccount = async(accountDataInput) => {
if (Array.isArray(accountDataInput)) {
insertAccounts(accountDataInput);
await insertAccounts(accountDataInput);
} else {
await setAccount(accountDataInput);
await insertAccount();
@ -175,6 +186,11 @@ export default function HomePage() {
closeOptions();
};
const openImportAccountModal = () => {
showImportOptions();
closeOptions();
};
const closeEnterAccountModal = () => setShowEnterAccountModal(false);
const closeSwipeableMenu = () => {
@ -291,11 +307,11 @@ export default function HomePage() {
padding: 20,
borderRadius: 10,
width: 300,
height: 150,
height: 225,
position: "absolute",
top: "50%",
left: "50%",
transform: [{translateX: -150}, {translateY: -75}],
transform: [{translateX: -150}, {translateY: -112.5}],
}}
>
<TouchableOpacity
@ -312,6 +328,13 @@ export default function HomePage() {
<IconButton icon={"keyboard"} size={35} />
<Text style={{fontSize: 18}}>Enter Secret code</Text>
</TouchableOpacity>
<TouchableOpacity
style={{flexDirection: "row", alignItems: "center", marginTop: 10}}
onPress={openImportAccountModal}
>
<IconButton icon={"import"} size={35} />
<Text style={{fontSize: 18}}>Import from other app</Text>
</TouchableOpacity>
</Modal>
</Portal>

56
ImportManager.js Normal file
View File

@ -0,0 +1,56 @@
// 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 {useActionSheet} from "@expo/react-native-action-sheet";
import {importFromMSAuth} from "./MSAuthImportLogic";
const importApps = [
{name: "Google Authenticator", useScanner: true},
{name: "Microsoft Authenticator", importFunction: importFromMSAuth},
];
export const useImportManager = (onImportComplete, onError, onOpenScanner) => {
const {showActionSheetWithOptions} = useActionSheet();
const showImportOptions = () => {
const options = [...importApps.map(app => app.name), "Cancel"];
const cancelButtonIndex = options.length - 1;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
title: "Select app to import from",
},
(selectedIndex) => {
if (selectedIndex !== cancelButtonIndex) {
const selectedApp = importApps[selectedIndex];
if (selectedApp.useScanner) {
onOpenScanner();
} else if (selectedApp.importFunction) {
selectedApp.importFunction()
.then(result => {
if (result) {onImportComplete(result);}
})
.catch(onError);
} else {
onError(new Error(`Import function not implemented for ${selectedApp.name}`));
}
}
}
);
};
return {showImportOptions};
};

84
MSAuthImportLogic.js Normal file
View File

@ -0,0 +1,84 @@
// 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 * as DocumentPicker from "expo-document-picker";
import * as FileSystem from "expo-file-system";
import {openDatabaseSync} from "expo-sqlite/next";
const SQLITE_DIR = `${FileSystem.documentDirectory}SQLite`;
const getRandomDBName = () => {
const randomString = Math.random().toString(36).substring(2, 15);
const timestamp = Date.now();
return `${randomString}_${timestamp}.db`;
};
const createDirectory = async(dir) => {
try {
if (!(await FileSystem.getInfoAsync(dir)).exists) {
await FileSystem.makeDirectoryAsync(dir, {intermediates: true});
}
} catch (error) {
throw new Error(`Error creating directory: ${error.message}`);
}
};
const queryMicrosoftAuthenticatorDatabase = async(db) => {
return await db.getAllAsync("SELECT name, username, oath_secret_key FROM accounts WHERE account_type = 0");
};
const formatMicrosoftAuthenticatorData = (rows) => {
return rows.map(row => {
const {name, username, oath_secret_key} = row;
return {issuer: name, accountName: username, secretKey: oath_secret_key};
});
};
export const importFromMSAuth = async() => {
const dbName = getRandomDBName();
const internalDbName = `${SQLITE_DIR}/${dbName}`;
try {
const result = await DocumentPicker.getDocumentAsync({
multiple: false,
copyToCacheDirectory: false,
});
if (!result.canceled) {
const file = result.assets[0];
if ((await FileSystem.getInfoAsync(file.uri)).exists) {
await createDirectory(SQLITE_DIR);
await FileSystem.copyAsync({from: file.uri, to: internalDbName});
try {
const db = openDatabaseSync(dbName);
const rows = await queryMicrosoftAuthenticatorDatabase(db);
if (rows.length === 0) {
throw new Error("No data found in Microsoft Authenticator database");
}
return formatMicrosoftAuthenticatorData(rows);
} catch (dbError) {
if (dbError.message.includes("file is not a database")) {
throw new Error("file is not a database");
}
throw new Error(dbError.message);
}
}
}
} catch (error) {
throw new Error(`Error importing from Microsoft Authenticator: ${error.message}`);
} finally {
await FileSystem.deleteAsync(internalDbName, {idempotent: true});
}
};

8591
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -21,18 +21,19 @@
"casdoor-react-native-sdk": "1.1.0",
"drizzle-orm": "^0.33.0",
"eslint-plugin-import": "^2.28.1",
"expo": "~51.0.34",
"expo": "~51.0.37",
"expo-asset": "~10.0.10",
"expo-camera": "~15.0.16",
"expo-crypto": "~13.0.2",
"expo-dev-client": "~4.0.27",
"expo-dev-client": "~4.0.28",
"expo-document-picker": "~12.0.2",
"expo-drizzle-studio-plugin": "^0.0.2",
"expo-image": "~1.13.0",
"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.25",
"expo-updates": "~0.25.27",
"hi-base32": "^0.5.1",
"hotp-totp": "^1.0.6",
"prop-types": "^15.8.1",

View File

@ -129,7 +129,7 @@ const useEditAccountStore = create((set, get) => ({
}
},
insertAccounts: (accounts) => {
insertAccounts: async(accounts) => {
try {
db.transaction((tx) => {
const insertWithDuplicateCheck = (baseAccName, issuer, secretKey) => {