mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
feat(auth): add multi-account support with PIN/password protection
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
import * as Crypto from "expo-crypto";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { storage } from "./mmkv";
|
||||
|
||||
const CREDENTIAL_KEY_PREFIX = "credential_";
|
||||
const MULTI_ACCOUNT_MIGRATED_KEY = "multiAccountMigrated";
|
||||
|
||||
/**
|
||||
* Security type for saved accounts.
|
||||
*/
|
||||
export type AccountSecurityType = "none" | "pin" | "password";
|
||||
|
||||
/**
|
||||
* Credential stored in secure storage for a specific account.
|
||||
*/
|
||||
export interface ServerCredential {
|
||||
serverUrl: string;
|
||||
serverName: string;
|
||||
@@ -10,50 +20,118 @@ export interface ServerCredential {
|
||||
userId: string;
|
||||
username: string;
|
||||
savedAt: number;
|
||||
securityType: AccountSecurityType;
|
||||
pinHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account summary stored in SavedServer for display in UI.
|
||||
*/
|
||||
export interface SavedServerAccount {
|
||||
userId: string;
|
||||
username: string;
|
||||
securityType: AccountSecurityType;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server with multiple saved accounts.
|
||||
*/
|
||||
export interface SavedServer {
|
||||
address: string;
|
||||
name?: string;
|
||||
accounts: SavedServerAccount[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy interface for migration purposes.
|
||||
*/
|
||||
interface LegacySavedServer {
|
||||
address: string;
|
||||
name?: string;
|
||||
hasCredentials?: boolean;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy credential interface for migration purposes.
|
||||
*/
|
||||
interface LegacyServerCredential {
|
||||
serverUrl: string;
|
||||
serverName: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export interface SavedServer {
|
||||
address: string;
|
||||
name?: string;
|
||||
hasCredentials?: boolean;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode server URL to valid secure store key.
|
||||
* Secure store keys must be alphanumeric with underscores.
|
||||
* Encode server URL to valid secure store key (legacy, for migration).
|
||||
*/
|
||||
export function serverUrlToKey(serverUrl: string): string {
|
||||
// Use base64 encoding, replace non-alphanumeric chars with underscores
|
||||
const encoded = btoa(serverUrl).replace(/[^a-zA-Z0-9]/g, "_");
|
||||
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credentials for a server to secure storage.
|
||||
* Generate credential key for a specific account (serverUrl + userId).
|
||||
*/
|
||||
export async function saveServerCredential(
|
||||
credential: ServerCredential,
|
||||
): Promise<void> {
|
||||
const key = serverUrlToKey(credential.serverUrl);
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(credential));
|
||||
export function credentialKey(serverUrl: string, userId: string): string {
|
||||
const combined = `${serverUrl}:${userId}`;
|
||||
const encoded = btoa(combined).replace(/[^a-zA-Z0-9]/g, "_");
|
||||
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
|
||||
}
|
||||
|
||||
// Update previousServers to mark this server as having credentials
|
||||
updatePreviousServerCredentialFlag(
|
||||
credential.serverUrl,
|
||||
true,
|
||||
credential.username,
|
||||
credential.serverName,
|
||||
/**
|
||||
* Hash a PIN using SHA256.
|
||||
*/
|
||||
export async function hashPIN(pin: string): Promise<string> {
|
||||
return await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
pin,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve credentials for a server from secure storage.
|
||||
* Verify a PIN against stored hash.
|
||||
*/
|
||||
export async function getServerCredential(
|
||||
export async function verifyAccountPIN(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
pin: string,
|
||||
): Promise<boolean> {
|
||||
const credential = await getAccountCredential(serverUrl, userId);
|
||||
if (!credential?.pinHash) return false;
|
||||
const inputHash = await hashPIN(pin);
|
||||
return inputHash === credential.pinHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credential for a specific account.
|
||||
*/
|
||||
export async function saveAccountCredential(
|
||||
credential: ServerCredential,
|
||||
): Promise<void> {
|
||||
const key = credentialKey(credential.serverUrl, credential.userId);
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(credential));
|
||||
|
||||
// Update previousServers to include this account
|
||||
addAccountToServer(credential.serverUrl, credential.serverName, {
|
||||
userId: credential.userId,
|
||||
username: credential.username,
|
||||
securityType: credential.securityType,
|
||||
savedAt: credential.savedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve credential for a specific account.
|
||||
*/
|
||||
export async function getAccountCredential(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
): Promise<ServerCredential | null> {
|
||||
const key = serverUrlToKey(serverUrl);
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
const stored = await SecureStore.getItemAsync(key);
|
||||
|
||||
if (stored) {
|
||||
@@ -67,57 +145,138 @@ export async function getServerCredential(
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete credentials for a server from secure storage.
|
||||
* Delete credential for a specific account.
|
||||
*/
|
||||
export async function deleteServerCredential(serverUrl: string): Promise<void> {
|
||||
const key = serverUrlToKey(serverUrl);
|
||||
export async function deleteAccountCredential(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
|
||||
// Update previousServers to mark this server as not having credentials
|
||||
updatePreviousServerCredentialFlag(serverUrl, false);
|
||||
// Remove account from previousServers
|
||||
removeAccountFromServer(serverUrl, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials exist for a server (without retrieving them).
|
||||
* Get all credentials for a server (by iterating through accounts).
|
||||
*/
|
||||
export async function hasServerCredential(serverUrl: string): Promise<boolean> {
|
||||
const key = serverUrlToKey(serverUrl);
|
||||
export async function getServerAccounts(
|
||||
serverUrl: string,
|
||||
): Promise<ServerCredential[]> {
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
if (!server) return [];
|
||||
|
||||
const credentials: ServerCredential[] = [];
|
||||
for (const account of server.accounts) {
|
||||
const credential = await getAccountCredential(serverUrl, account.userId);
|
||||
if (credential) {
|
||||
credentials.push(credential);
|
||||
}
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials exist for a specific account.
|
||||
*/
|
||||
export async function hasAccountCredential(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
const stored = await SecureStore.getItemAsync(key);
|
||||
return stored !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all stored credentials for all servers.
|
||||
* Delete all credentials for all accounts on all servers.
|
||||
*/
|
||||
export async function clearAllCredentials(): Promise<void> {
|
||||
const previousServers = getPreviousServers();
|
||||
|
||||
for (const server of previousServers) {
|
||||
await deleteServerCredential(server.address);
|
||||
for (const account of server.accounts) {
|
||||
const key = credentialKey(server.address, account.userId);
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all accounts from servers
|
||||
const clearedServers = previousServers.map((server) => ({
|
||||
...server,
|
||||
accounts: [],
|
||||
}));
|
||||
storage.set("previousServers", JSON.stringify(clearedServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to update the previousServers list in MMKV with credential status.
|
||||
* Add or update an account in a server's accounts list.
|
||||
*/
|
||||
function updatePreviousServerCredentialFlag(
|
||||
function addAccountToServer(
|
||||
serverUrl: string,
|
||||
hasCredentials: boolean,
|
||||
username?: string,
|
||||
serverName?: string,
|
||||
serverName: string,
|
||||
account: SavedServerAccount,
|
||||
): void {
|
||||
const previousServers = getPreviousServers();
|
||||
let serverFound = false;
|
||||
|
||||
const updatedServers = previousServers.map((server) => {
|
||||
if (server.address === serverUrl) {
|
||||
serverFound = true;
|
||||
// Check if account already exists
|
||||
const existingIndex = server.accounts.findIndex(
|
||||
(a) => a.userId === account.userId,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing account
|
||||
const updatedAccounts = [...server.accounts];
|
||||
updatedAccounts[existingIndex] = account;
|
||||
return {
|
||||
...server,
|
||||
name: serverName || server.name,
|
||||
accounts: updatedAccounts,
|
||||
};
|
||||
}
|
||||
// Add new account
|
||||
return {
|
||||
...server,
|
||||
hasCredentials,
|
||||
username: username || server.username,
|
||||
name: serverName || server.name,
|
||||
accounts: [...server.accounts, account],
|
||||
};
|
||||
}
|
||||
return server;
|
||||
});
|
||||
|
||||
// If server not found, add it
|
||||
if (!serverFound) {
|
||||
updatedServers.push({
|
||||
address: serverUrl,
|
||||
name: serverName,
|
||||
accounts: [account],
|
||||
});
|
||||
}
|
||||
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an account from a server's accounts list.
|
||||
*/
|
||||
function removeAccountFromServer(serverUrl: string, userId: string): void {
|
||||
const previousServers = getPreviousServers();
|
||||
|
||||
const updatedServers = previousServers.map((server) => {
|
||||
if (server.address === serverUrl) {
|
||||
return {
|
||||
...server,
|
||||
accounts: server.accounts.filter((a) => a.userId !== userId),
|
||||
};
|
||||
}
|
||||
return server;
|
||||
});
|
||||
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
|
||||
@@ -137,46 +296,217 @@ export function getPreviousServers(): SavedServer[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a server from the previous servers list and delete its credentials.
|
||||
* Remove a server from the list and delete all its account credentials.
|
||||
*/
|
||||
export async function removeServerFromList(serverUrl: string): Promise<void> {
|
||||
// First delete any saved credentials
|
||||
await deleteServerCredential(serverUrl);
|
||||
// Then remove from the list
|
||||
const previousServers = getPreviousServers();
|
||||
const filtered = previousServers.filter((s) => s.address !== serverUrl);
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
|
||||
// Delete all account credentials for this server
|
||||
if (server) {
|
||||
for (const account of server.accounts) {
|
||||
const key = credentialKey(serverUrl, account.userId);
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove server from list
|
||||
const filtered = servers.filter((s) => s.address !== serverUrl);
|
||||
storage.set("previousServers", JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate existing previousServers to new format (add hasCredentials: false).
|
||||
* Add a server to the list without credentials (for server discovery).
|
||||
*/
|
||||
export function addServerToList(serverUrl: string, serverName?: string): void {
|
||||
const servers = getPreviousServers();
|
||||
const existing = servers.find((s) => s.address === serverUrl);
|
||||
|
||||
if (existing) {
|
||||
// Update name if provided
|
||||
if (serverName) {
|
||||
const updated = servers.map((s) =>
|
||||
s.address === serverUrl ? { ...s, name: serverName } : s,
|
||||
);
|
||||
storage.set("previousServers", JSON.stringify(updated));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new server with empty accounts
|
||||
const newServer: SavedServer = {
|
||||
address: serverUrl,
|
||||
name: serverName,
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
// Keep max 10 servers
|
||||
const updatedServers = [newServer, ...servers].slice(0, 10);
|
||||
storage.set("previousServers", JSON.stringify(updatedServers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from legacy single-account format to multi-account format.
|
||||
* Should be called on app startup.
|
||||
*/
|
||||
export async function migrateServersList(): Promise<void> {
|
||||
export async function migrateToMultiAccount(): Promise<void> {
|
||||
// Check if already migrated
|
||||
if (storage.getBoolean(MULTI_ACCOUNT_MIGRATED_KEY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = storage.getString("previousServers");
|
||||
if (!stored) return;
|
||||
if (!stored) {
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const servers = JSON.parse(stored);
|
||||
// Check if migration needed (old format doesn't have hasCredentials)
|
||||
if (servers.length > 0 && servers[0].hasCredentials === undefined) {
|
||||
const migrated = servers.map((server: SavedServer) => ({
|
||||
address: server.address,
|
||||
name: server.name,
|
||||
hasCredentials: false,
|
||||
username: undefined,
|
||||
}));
|
||||
storage.set("previousServers", JSON.stringify(migrated));
|
||||
|
||||
// Check if already in new format (has accounts array)
|
||||
if (servers.length > 0 && Array.isArray(servers[0].accounts)) {
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Migrate from legacy format
|
||||
const migratedServers: SavedServer[] = [];
|
||||
|
||||
for (const legacyServer of servers as LegacySavedServer[]) {
|
||||
const newServer: SavedServer = {
|
||||
address: legacyServer.address,
|
||||
name: legacyServer.name,
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
// Try to get existing credential using legacy key
|
||||
if (legacyServer.hasCredentials) {
|
||||
const legacyKey = serverUrlToKey(legacyServer.address);
|
||||
const storedCred = await SecureStore.getItemAsync(legacyKey);
|
||||
|
||||
if (storedCred) {
|
||||
try {
|
||||
const legacyCred = JSON.parse(storedCred) as LegacyServerCredential;
|
||||
|
||||
// Create new credential with security type
|
||||
const newCredential: ServerCredential = {
|
||||
...legacyCred,
|
||||
securityType: "none", // Existing accounts get no protection (preserve quick-login)
|
||||
};
|
||||
|
||||
// Save with new key format
|
||||
const newKey = credentialKey(
|
||||
legacyServer.address,
|
||||
legacyCred.userId,
|
||||
);
|
||||
await SecureStore.setItemAsync(
|
||||
newKey,
|
||||
JSON.stringify(newCredential),
|
||||
);
|
||||
|
||||
// Delete old key
|
||||
await SecureStore.deleteItemAsync(legacyKey);
|
||||
|
||||
// Add account to server
|
||||
newServer.accounts.push({
|
||||
userId: legacyCred.userId,
|
||||
username: legacyCred.username,
|
||||
securityType: "none",
|
||||
savedAt: legacyCred.savedAt,
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid credentials
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migratedServers.push(newServer);
|
||||
}
|
||||
|
||||
storage.set("previousServers", JSON.stringify(migratedServers));
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
} catch {
|
||||
// If parsing fails, reset to empty array
|
||||
storage.set("previousServers", "[]");
|
||||
storage.set(MULTI_ACCOUNT_MIGRATED_KEY, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate current session credentials to secure storage.
|
||||
* Should be called on app startup for existing users.
|
||||
* Update account's token after successful login.
|
||||
*/
|
||||
export async function updateAccountToken(
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
newToken: string,
|
||||
): Promise<void> {
|
||||
const credential = await getAccountCredential(serverUrl, userId);
|
||||
if (credential) {
|
||||
credential.token = newToken;
|
||||
credential.savedAt = Date.now();
|
||||
const key = credentialKey(serverUrl, userId);
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(credential));
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy functions for backward compatibility during transition
|
||||
// These delegate to new functions
|
||||
|
||||
/**
|
||||
* @deprecated Use saveAccountCredential instead
|
||||
*/
|
||||
export async function saveServerCredential(
|
||||
credential: ServerCredential,
|
||||
): Promise<void> {
|
||||
return saveAccountCredential(credential);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getAccountCredential instead
|
||||
*/
|
||||
export async function getServerCredential(
|
||||
serverUrl: string,
|
||||
): Promise<ServerCredential | null> {
|
||||
// Try to get first account's credential for backward compatibility
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
if (server && server.accounts.length > 0) {
|
||||
return getAccountCredential(serverUrl, server.accounts[0].userId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use deleteAccountCredential instead
|
||||
*/
|
||||
export async function deleteServerCredential(serverUrl: string): Promise<void> {
|
||||
// Delete first account for backward compatibility
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
if (server && server.accounts.length > 0) {
|
||||
return deleteAccountCredential(serverUrl, server.accounts[0].userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use hasAccountCredential instead
|
||||
*/
|
||||
export async function hasServerCredential(serverUrl: string): Promise<boolean> {
|
||||
const servers = getPreviousServers();
|
||||
const server = servers.find((s) => s.address === serverUrl);
|
||||
return server ? server.accounts.length > 0 : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use migrateToMultiAccount instead
|
||||
*/
|
||||
export async function migrateServersList(): Promise<void> {
|
||||
return migrateToMultiAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use saveAccountCredential instead
|
||||
*/
|
||||
export async function migrateCurrentSessionToSecureStorage(
|
||||
serverUrl: string,
|
||||
@@ -185,17 +515,18 @@ export async function migrateCurrentSessionToSecureStorage(
|
||||
username: string,
|
||||
serverName?: string,
|
||||
): Promise<void> {
|
||||
const existingCredential = await getServerCredential(serverUrl);
|
||||
const existingCredential = await getAccountCredential(serverUrl, userId);
|
||||
|
||||
// Only save if not already saved
|
||||
if (!existingCredential) {
|
||||
await saveServerCredential({
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: serverName || "",
|
||||
token,
|
||||
userId,
|
||||
username,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user