diff --git a/.Jules/changelog.md b/.Jules/changelog.md index 11fc864e..55a45600 100644 --- a/.Jules/changelog.md +++ b/.Jules/changelog.md @@ -7,6 +7,15 @@ ## [Unreleased] ### Added +- **Mobile Biometric Authentication:** Implemented FaceID/TouchID login support. + - **Features:** + - "Enable Biometric Login" toggle in Account settings (visible only if hardware supports it). + - "Login with FaceID" button on Login screen. + - Secure credential storage using `expo-secure-store`. + - Automatic handling of user switching (disables biometrics if new user logs in). + - Syncs tokens to secure storage on refresh. + - **Technical:** Integrated `expo-local-authentication` and `expo-secure-store`. Updated `mobile/context/AuthContext.js` with secure storage logic. Added `NSFaceIDUsageDescription` to iOS config. + - **Password Strength Meter:** Added a visual password strength indicator to the signup form. - **Features:** - Real-time strength calculation (Length, Uppercase, Lowercase, Number, Symbol). diff --git a/.Jules/knowledge.md b/.Jules/knowledge.md index 43a9ab01..285bffef 100644 --- a/.Jules/knowledge.md +++ b/.Jules/knowledge.md @@ -299,6 +299,16 @@ Commonly used components: - `` and `` for overlays - `` for loading states +### Mobile Authentication & Biometrics + +**Date:** 2026-02-14 +**Context:** Implementing FaceID/TouchID login + +1. **Dual Storage Strategy:** Use `AsyncStorage` for active session (cleared on logout) and `SecureStore` for persistent biometric credentials (kept on logout). +2. **User Switching:** When a new user logs in manually, check if their ID matches the one in `SecureStore`. If not, disable biometrics and clear secure storage to prevent account crossover. +3. **Token Sync:** Always update `SecureStore` when tokens refresh (in `useEffect`), otherwise biometric login will fail with expired tokens. +4. **iOS Config:** Must add `NSFaceIDUsageDescription` to `app.json` > `ios` > `infoPlist` to avoid crashes. + ### Safe Area Pattern **Date:** 2026-01-01 diff --git a/.Jules/todo.md b/.Jules/todo.md index ebb0c7a5..b98aa5a0 100644 --- a/.Jules/todo.md +++ b/.Jules/todo.md @@ -124,12 +124,12 @@ ### Mobile -- [ ] **[ux]** Biometric authentication option - - Files: `mobile/context/AuthContext.js`, add local auth - - Context: FaceID/TouchID for quick login - - Impact: Faster, more secure login - - Size: ~70 lines - - Added: 2026-01-01 +- [x] **[ux]** Biometric authentication option + - Completed: 2026-02-14 + - Files: `mobile/context/AuthContext.js`, `mobile/screens/LoginScreen.js`, `mobile/screens/AccountScreen.js` + - Context: Integrated FaceID/TouchID for quick, secure login + - Impact: Faster login experience while maintaining security + - Size: ~150 lines --- diff --git a/mobile/app.json b/mobile/app.json index db409293..4a2bdec5 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -16,7 +16,8 @@ "ios": { "supportsTablet": true, "infoPlist": { - "NSPhotoLibraryUsageDescription": "Allow Splitwiser to select a group icon from your photo library." + "NSPhotoLibraryUsageDescription": "Allow Splitwiser to select a group icon from your photo library.", + "NSFaceIDUsageDescription": "Allow Splitwiser to use FaceID for quick and secure login." } }, "android": { diff --git a/mobile/context/AuthContext.js b/mobile/context/AuthContext.js index 1caf5326..c2f93af9 100644 --- a/mobile/context/AuthContext.js +++ b/mobile/context/AuthContext.js @@ -1,6 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { createContext, useEffect, useState } from "react"; import * as authApi from "../api/auth"; +import * as LocalAuthentication from "expo-local-authentication"; +import * as SecureStore from "expo-secure-store"; import { clearAuthTokens, setAuthTokens, @@ -14,6 +16,8 @@ export const AuthProvider = ({ children }) => { const [token, setToken] = useState(null); const [refresh, setRefresh] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [isBiometricSupported, setIsBiometricSupported] = useState(false); + const [isBiometricEnabled, setIsBiometricEnabled] = useState(false); // Load token and user data from AsyncStorage on app start useEffect(() => { @@ -21,7 +25,17 @@ export const AuthProvider = ({ children }) => { try { const storedToken = await AsyncStorage.getItem("auth_token"); const storedRefresh = await AsyncStorage.getItem("refresh_token"); - const storedUser = await AsyncStorage.getItem("user_data"); + const storedUser = await AsyncStorage.getItem("user_data"); + const biometricEnabled = await AsyncStorage.getItem("biometric_enabled"); + + if (biometricEnabled === 'true') { + setIsBiometricEnabled(true); + } + + // Check biometric support + const hasHardware = await LocalAuthentication.hasHardwareAsync(); + const isEnrolled = await LocalAuthentication.isEnrolledAsync(); + setIsBiometricSupported(hasHardware && isEnrolled); if (storedToken && storedUser) { setToken(storedToken); @@ -123,6 +137,20 @@ export const AuthProvider = ({ children }) => { ? { ...userData, _id: userData.id } : userData; setUser(normalizedUser); + + // If biometric is enabled, check if we are logging in as a different user + if (isBiometricEnabled) { + const storedUserData = await SecureStore.getItemAsync("secure_user_data"); + const storedUser = storedUserData ? JSON.parse(storedUserData) : null; + + // If there is a stored user and it doesn't match the new user, disable biometrics + // to prevent account crossover/confusion. + if (storedUser && storedUser._id !== normalizedUser._id) { + await disableBiometrics(); + } + // If same user (or no stored user), the useEffect will handle syncing the new tokens + } + return true; } catch (error) { console.error( @@ -148,10 +176,19 @@ export const AuthProvider = ({ children }) => { const logout = async () => { try { - // Clear stored authentication data + // Clear stored authentication data from AsyncStorage await AsyncStorage.removeItem("auth_token"); await AsyncStorage.removeItem("refresh_token"); await AsyncStorage.removeItem("user_data"); + + // NOTE: We deliberately do NOT clear SecureStore if biometric is enabled + // This allows "Login with FaceID" to work after logout + if (!isBiometricEnabled) { + await SecureStore.deleteItemAsync("secure_auth_token"); + await SecureStore.deleteItemAsync("secure_refresh_token"); + await SecureStore.deleteItemAsync("secure_user_data"); + } + } catch (error) { console.error("Failed to clear stored authentication:", error); } @@ -162,6 +199,95 @@ export const AuthProvider = ({ children }) => { await clearAuthTokens(); }; + const enableBiometrics = async () => { + try { + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: "Authenticate to enable biometric login", + }); + + if (result.success) { + if (token && refresh && user) { + await SecureStore.setItemAsync("secure_auth_token", token); + await SecureStore.setItemAsync("secure_refresh_token", refresh); + await SecureStore.setItemAsync("secure_user_data", JSON.stringify(user)); + await AsyncStorage.setItem("biometric_enabled", "true"); + setIsBiometricEnabled(true); + return true; + } + } + return false; + } catch (error) { + console.error("Failed to enable biometrics:", error); + return false; + } + }; + + const disableBiometrics = async () => { + try { + await SecureStore.deleteItemAsync("secure_auth_token"); + await SecureStore.deleteItemAsync("secure_refresh_token"); + await SecureStore.deleteItemAsync("secure_user_data"); + await AsyncStorage.setItem("biometric_enabled", "false"); + setIsBiometricEnabled(false); + return true; + } catch (error) { + console.error("Failed to disable biometrics:", error); + return false; + } + }; + + const loginWithBiometrics = async () => { + try { + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: "Login with biometrics", + }); + + if (result.success) { + const storedToken = await SecureStore.getItemAsync("secure_auth_token"); + const storedRefresh = await SecureStore.getItemAsync("secure_refresh_token"); + const storedUser = await SecureStore.getItemAsync("secure_user_data"); + + if (storedToken && storedUser) { + setToken(storedToken); + setRefresh(storedRefresh); + await setAuthTokens({ + newAccessToken: storedToken, + newRefreshToken: storedRefresh || storedToken, // Fallback if refresh missing + }); + + const parsed = JSON.parse(storedUser); + const normalized = parsed?._id + ? parsed + : parsed?.id + ? { ...parsed, _id: parsed.id } + : parsed; + setUser(normalized); + return true; + } + } + return false; + } catch (error) { + console.error("Biometric login failed:", error); + return false; + } + }; + + // Sync tokens and user data to SecureStore whenever they change, if biometrics is enabled + useEffect(() => { + const syncSecureStore = async () => { + if (isBiometricEnabled && token && refresh && user) { + try { + await SecureStore.setItemAsync("secure_auth_token", token); + await SecureStore.setItemAsync("secure_refresh_token", refresh); + await SecureStore.setItemAsync("secure_user_data", JSON.stringify(user)); + } catch (error) { + console.error("Failed to sync secure store:", error); + } + } + }; + syncSecureStore(); + }, [token, refresh, user, isBiometricEnabled]); + const updateUserInContext = (updatedUser) => { // Normalize on updates too const normalizedUser = updatedUser?._id @@ -178,10 +304,15 @@ export const AuthProvider = ({ children }) => { user, token, isLoading, + isBiometricSupported, + isBiometricEnabled, login, signup, logout, updateUserInContext, + enableBiometrics, + disableBiometrics, + loginWithBiometrics }} > {children} diff --git a/mobile/package-lock.json b/mobile/package-lock.json index c3452165..8628e6c1 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -17,6 +17,8 @@ "expo": "^54.0.25", "expo-haptics": "~15.0.8", "expo-image-picker": "~17.0.8", + "expo-local-authentication": "^17.0.8", + "expo-secure-store": "^15.0.8", "expo-status-bar": "~3.0.8", "react": "19.1.0", "react-dom": "19.1.0", @@ -4622,6 +4624,18 @@ "react": "*" } }, + "node_modules/expo-local-authentication": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-17.0.8.tgz", + "integrity": "sha512-Q5fXHhu6w3pVPlFCibU72SYIAN+9wX7QpFn9h49IUqs0Equ44QgswtGrxeh7fdnDqJrrYGPet5iBzjnE70uolA==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.22", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.22.tgz", @@ -4651,6 +4665,15 @@ "react-native": "*" } }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.4.tgz", diff --git a/mobile/package.json b/mobile/package.json index a425a9c1..1b421037 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -18,6 +18,8 @@ "expo": "^54.0.25", "expo-haptics": "~15.0.8", "expo-image-picker": "~17.0.8", + "expo-local-authentication": "^17.0.8", + "expo-secure-store": "^15.0.8", "expo-status-bar": "~3.0.8", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/mobile/screens/AccountScreen.js b/mobile/screens/AccountScreen.js index ac130a6d..e7ce360a 100644 --- a/mobile/screens/AccountScreen.js +++ b/mobile/screens/AccountScreen.js @@ -1,11 +1,18 @@ import { useContext } from "react"; import { Alert, StyleSheet, View } from "react-native"; -import { Appbar, Avatar, Divider, List, Text } from "react-native-paper"; +import { Appbar, Avatar, Divider, List, Switch, Text } from "react-native-paper"; import { HapticListItem } from '../components/ui/HapticList'; import { AuthContext } from "../context/AuthContext"; const AccountScreen = ({ navigation }) => { - const { user, logout } = useContext(AuthContext); + const { + user, + logout, + isBiometricSupported, + isBiometricEnabled, + enableBiometrics, + disableBiometrics + } = useContext(AuthContext); const handleLogout = () => { logout(); @@ -15,6 +22,20 @@ const AccountScreen = ({ navigation }) => { Alert.alert("Coming Soon", "This feature is not yet implemented."); }; + const toggleBiometric = async (value) => { + if (value) { + const success = await enableBiometrics(); + if (!success) { + Alert.alert("Error", "Failed to enable biometric authentication."); + } + } else { + const success = await disableBiometrics(); + if (!success) { + Alert.alert("Error", "Failed to disable biometric authentication."); + } + } + }; + return ( @@ -52,6 +73,28 @@ const AccountScreen = ({ navigation }) => { accessibilityRole="button" /> + + {isBiometricSupported && ( + <> + } + right={() => ( + + )} + onPress={() => toggleBiometric(!isBiometricEnabled)} + accessibilityLabel="Enable Biometric Login" + accessibilityRole="switch" + accessibilityState={{ checked: isBiometricEnabled }} + /> + + + )} + } diff --git a/mobile/screens/LoginScreen.js b/mobile/screens/LoginScreen.js index 194db5bb..6c8ddd90 100644 --- a/mobile/screens/LoginScreen.js +++ b/mobile/screens/LoginScreen.js @@ -8,7 +8,7 @@ const LoginScreen = ({ navigation }) => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); - const { login } = useContext(AuthContext); + const { login, loginWithBiometrics, isBiometricEnabled, isBiometricSupported } = useContext(AuthContext); const handleLogin = async () => { if (!email || !password) { @@ -23,6 +23,16 @@ const LoginScreen = ({ navigation }) => { } }; + const handleBiometricLogin = async () => { + setIsLoading(true); + const success = await loginWithBiometrics(); + if (success) { + return; + } + setIsLoading(false); + Alert.alert('Biometric Login Failed', 'Please try again or use your password.'); + }; + return ( Welcome Back! @@ -54,6 +64,23 @@ const LoginScreen = ({ navigation }) => { > Login + + {isBiometricEnabled && isBiometricSupported && ( + + Login with FaceID/TouchID + + )} + navigation.navigate("Signup")} style={styles.button}