diff --git a/.vscode/AliasVault.code-workspace b/.vscode/AliasVault.code-workspace
index 6c1c3e307..eec6c1292 100644
--- a/.vscode/AliasVault.code-workspace
+++ b/.vscode/AliasVault.code-workspace
@@ -24,6 +24,7 @@
}
],
"settings": {
- "java.configuration.updateBuildConfiguration": "disabled"
+ "java.configuration.updateBuildConfiguration": "disabled",
+ "i18n-ally.keystyle": "nested"
}
}
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5f1228e53..9cede33eb 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -10,8 +10,6 @@ The basic premise is that the master password chosen by the user upon registrati
- **Vault Data**: Your entire vault (passwords, usernames, notes, passkeys, email addresses, etc.) is fully encrypted client-side before being sent to the server. The server cannot decrypt any vault contents.
- **Email Contents**: When emails are received by the server, their contents are immediately encrypted with your public key (from your vault) before being saved. Only you can decrypt and read these emails with your private key.
-*Note: email aliases are stored on the server as "claims" which are linked to internal user IDs for routing purposes.*
-
This ensures that even if the AliasVault servers are compromised, vault contents and email messages remain secure and unreadable.
## Encryption algorithms
@@ -25,6 +23,7 @@ The following encryption algorithms and standards are used by AliasVault:
### Additional Features
- [RSA-OAEP](#rsa-oaep) - Email encryption
- [Passkeys (WebAuthn)](#passkeys-webauthn) - Passwordless authentication
+- [Login with Mobile](#login-with-mobile) - Unlock vault in web app / browser extension via mobile app
Below is a detailed explanation of each encryption algorithm and standard.
@@ -138,3 +137,31 @@ All implementations follow the WebAuthn Level 2 specification and use:
- Eliminates phishing risks through cryptographic domain binding
More information about WebAuthn can be found on the [WebAuthn specification](https://www.w3.org/TR/webauthn-2/) page.
+
+### Login with Mobile
+AliasVault provides a secure "Login with Mobile" feature that allows users to unlock their vault on web browsers or browser extensions by scanning a QR code with their authenticated mobile app. This convenient authentication method maintains zero-knowledge security through hybrid encryption.
+
+#### Implementation Details
+The mobile login system combines RSA-2048 asymmetric encryption with AES-256-GCM symmetric encryption:
+
+1. **Initiation**: Browser/extension client generates an RSA-2048 key pair locally and sends the public key to the server, which returns a unique request ID displayed as a QR code.
+
+2. **Authorization**: Mobile app scans the QR code, encrypts the user's vault decryption key with the RSA public key, and sends it to the server.
+
+3. **Retrieval**: Browser client polls the server for completion. When ready, the server:
+ - Generates fresh JWT tokens for the session
+ - Creates a random AES-256 symmetric key
+ - Encrypts tokens and username with the symmetric key
+ - Encrypts the symmetric key with the client's RSA public key
+ - Returns encrypted data and immediately purges it from the database
+
+4. **Decryption**: Client uses its RSA private key to decrypt the symmetric key, then uses the symmetric key to decrypt tokens and username, and the RSA private key to decrypt the vault decryption key.
+
+#### Security Properties
+- **Zero-Knowledge**: Server never accesses the vault decryption key in plaintext
+- **One-Time Use**: Requests cannot be retrieved twice; data is immediately cleared after retrieval
+- **Automatic Expiration**: Unfulfilled requests expire after 2 minutes client-side (3 minutes server-side); fulfilled but unretrieved requests auto-delete within 24 hours
+- **MITM Protection**: Only the client with the RSA private key can decrypt the response
+- **Limited Attack Surface**: Short timeout window minimizes QR code interception risks
+
+More information about the mobile login flow can be found in the [Architecture Documentation](https://docs.aliasvault.net/architecture/#6-login-with-mobile).
diff --git a/apps/browser-extension/package-lock.json b/apps/browser-extension/package-lock.json
index 635ee2fe2..efa3e883d 100644
--- a/apps/browser-extension/package-lock.json
+++ b/apps/browser-extension/package-lock.json
@@ -15,6 +15,7 @@
"globals": "^16.0.0",
"i18next": "^25.3.1",
"otpauth": "^9.3.6",
+ "qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
@@ -30,6 +31,7 @@
"@types/chrome": "^0.0.280",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.13.10",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@types/sql.js": "^1.4.9",
@@ -2116,6 +2118,16 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+ "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz",
@@ -2973,7 +2985,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -4101,7 +4112,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -4114,7 +4124,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
"license": "MIT"
},
"node_modules/colorette": {
@@ -4428,6 +4437,15 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/decimal.js": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
@@ -4571,6 +4589,12 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -6000,7 +6024,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -6810,7 +6833,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8701,6 +8723,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/package-json": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz",
@@ -8830,7 +8861,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8990,6 +9020,15 @@
"pathe": "^2.0.3"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -9291,6 +9330,191 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qrcode/node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/qrcode/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/qrcode/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/qrcode/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/quansync": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz",
@@ -9647,12 +9871,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -9976,6 +10205,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
@@ -11900,6 +12135,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
"node_modules/which-typed-array": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json
index c66e6fd7f..6514ee13e 100644
--- a/apps/browser-extension/package.json
+++ b/apps/browser-extension/package.json
@@ -32,6 +32,7 @@
"globals": "^16.0.0",
"i18next": "^25.3.1",
"otpauth": "^9.3.6",
+ "qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.57.0",
@@ -47,6 +48,7 @@
"@types/chrome": "^0.0.280",
"@types/jsdom": "^21.1.7",
"@types/node": "^22.13.10",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@types/sql.js": "^1.4.9",
diff --git a/apps/browser-extension/src/entrypoints/popup/App.tsx b/apps/browser-extension/src/entrypoints/popup/App.tsx
index 9fd47c208..1cdd55547 100644
--- a/apps/browser-extension/src/entrypoints/popup/App.tsx
+++ b/apps/browser-extension/src/entrypoints/popup/App.tsx
@@ -112,7 +112,7 @@ const AppContent: React.FC<{
{loadingOverlay}
{message && (
- {message}
+ {message}
)}
{routesComponent}
@@ -135,8 +135,8 @@ const AppContent: React.FC<{
}}
>
{message && (
-
-
{message}
+
)}
{routesComponent}
diff --git a/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx b/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx
new file mode 100644
index 000000000..ca105b085
--- /dev/null
+++ b/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx
@@ -0,0 +1,244 @@
+import QRCode from 'qrcode';
+import React, { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { MobileLoginErrorCode } from '@/entrypoints/popup/types/MobileLoginErrorCode';
+import { MobileLoginUtility } from '@/entrypoints/popup/utils/MobileLoginUtility';
+
+import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult';
+import type { WebApiService } from '@/utils/WebApiService';
+
+interface IMobileUnlockModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: (result: MobileLoginResult) => Promise
;
+ webApi: WebApiService;
+ mode?: 'login' | 'unlock';
+}
+
+/**
+ * Modal component for mobile login/unlock via QR code scanning.
+ */
+const MobileUnlockModal: React.FC = ({
+ isOpen,
+ onClose,
+ onSuccess,
+ webApi,
+ mode = 'login'
+}) => {
+ const { t } = useTranslation();
+ const [qrCodeUrl, setQrCodeUrl] = useState(null);
+ const [error, setError] = useState(null);
+ const [timeRemaining, setTimeRemaining] = useState(120); // 2 minutes in seconds
+ const mobileLoginRef = useRef(null);
+ const countdownIntervalRef = useRef(null);
+
+ /**
+ * Get translated error message for error code.
+ */
+ const getErrorMessage = (errorCode: MobileLoginErrorCode): string => {
+ switch (errorCode) {
+ case MobileLoginErrorCode.TIMEOUT:
+ return t('auth.errors.mobileLoginRequestExpired');
+ case MobileLoginErrorCode.GENERIC:
+ default:
+ return t('common.errors.unknownError');
+ }
+ };
+
+ // Countdown timer effect
+ useEffect(() => {
+ if (qrCodeUrl && timeRemaining > 0 && isOpen) {
+ countdownIntervalRef.current = setInterval(() => {
+ setTimeRemaining(prev => {
+ if (prev <= 1) {
+ if (countdownIntervalRef.current) {
+ clearInterval(countdownIntervalRef.current);
+ }
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+
+ return (): void => {
+ if (countdownIntervalRef.current) {
+ clearInterval(countdownIntervalRef.current);
+ }
+ };
+ }
+ }, [qrCodeUrl, timeRemaining, isOpen]);
+
+ // Initialize mobile login when modal opens
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ /**
+ * Initialize mobile login on modal open.
+ */
+ const initiateMobileLogin = async (): Promise => {
+ try {
+ setError(null);
+ setQrCodeUrl(null);
+ setTimeRemaining(120);
+
+ // Initialize mobile login utility
+ if (!mobileLoginRef.current) {
+ mobileLoginRef.current = new MobileLoginUtility(webApi);
+ }
+
+ // Initiate mobile login and get QR code data
+ const requestId = await mobileLoginRef.current.initiate();
+
+ // Generate QR code with AliasVault prefix for mobile login
+ const qrData = `aliasvault://open/mobile-unlock/${requestId}`;
+ const qrDataUrl = await QRCode.toDataURL(qrData, {
+ width: 256,
+ margin: 2,
+ });
+
+ setQrCodeUrl(qrDataUrl);
+
+ // Start polling for response
+ await mobileLoginRef.current.startPolling(
+ async (result: MobileLoginResult) => {
+ try {
+ // Call success callback (parent handles loading state)
+ await onSuccess(result);
+ // Close modal after successful processing
+ handleClose();
+ } catch {
+ // Show error if success handler fails and hide QR code
+ setQrCodeUrl(null);
+ setError(MobileLoginErrorCode.GENERIC);
+ }
+ },
+ (errorCode) => {
+ // Hide QR code when error occurs
+ setQrCodeUrl(null);
+ setError(errorCode);
+ }
+ );
+ } catch (err) {
+ // err is a MobileLoginErrorCode thrown by initiate()
+ if (typeof err === 'string' && Object.values(MobileLoginErrorCode).includes(err as MobileLoginErrorCode)) {
+ setError(err as MobileLoginErrorCode);
+ } else {
+ setError(MobileLoginErrorCode.GENERIC);
+ }
+ }
+ };
+
+ initiateMobileLogin();
+
+ // Cleanup on unmount or when modal closes
+ return (): void => {
+ if (mobileLoginRef.current) {
+ mobileLoginRef.current.cleanup();
+ }
+ if (countdownIntervalRef.current) {
+ clearInterval(countdownIntervalRef.current);
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen]);
+
+ /**
+ * Handle modal close.
+ */
+ const handleClose = (): void => {
+ if (mobileLoginRef.current) {
+ mobileLoginRef.current.cleanup();
+ }
+ if (countdownIntervalRef.current) {
+ clearInterval(countdownIntervalRef.current);
+ }
+ setQrCodeUrl(null);
+ setError(null);
+ setTimeRemaining(120);
+ onClose();
+ };
+
+ /**
+ * Format time remaining as MM:SS.
+ */
+ const formatTime = (seconds: number): string => {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+
+ const title = mode === 'unlock' ? t('auth.unlockWithMobile') : t('auth.loginWithMobile');
+ const description = t('auth.scanQrCode');
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
+ {/* Close button */}
+
+ {t('common.close')}
+
+
+
+
+
+ {/* Content */}
+
+
+ {title}
+
+
+ {description}
+
+
+ {error && (
+
+ {getErrorMessage(error)}
+
+ )}
+
+ {qrCodeUrl && (
+
+
+
+ {formatTime(timeRemaining)}
+
+
+ )}
+
+ {!qrCodeUrl && !error && (
+
+ )}
+
+
+ {t('common.cancel')}
+
+
+
+
+
+ );
+};
+
+export default MobileUnlockModal;
diff --git a/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx b/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx
index 54b094d41..afe748458 100644
--- a/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx
+++ b/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/entrypoints/popup/components/Button';
+import MobileUnlockModal from '@/entrypoints/popup/components/Dialogs/MobileUnlockModal';
import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import LoginServerInfo from '@/entrypoints/popup/components/LoginServerInfo';
@@ -21,6 +22,7 @@ import { AppInfo } from '@/utils/AppInfo';
import type { VaultResponse, LoginResponse } from '@/utils/dist/shared/models/webapi';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { ApiAuthError } from '@/utils/types/errors/ApiAuthError';
+import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult';
import { storage } from '#imports';
@@ -47,6 +49,7 @@ const Login: React.FC = () => {
const [twoFactorCode, setTwoFactorCode] = useState('');
const [clientUrl, setClientUrl] = useState(null);
const [error, setError] = useState(null);
+ const [showMobileLoginModal, setShowMobileLoginModal] = useState(false);
const webApi = useWebApi();
const srpUtil = new SrpUtility(webApi);
@@ -272,6 +275,63 @@ const Login: React.FC = () => {
}
};
+ /**
+ * Handle successful mobile login
+ */
+ const handleMobileLoginSuccess = async (result: MobileLoginResult): Promise => {
+ showLoading();
+ try {
+ // Clear global message if set
+ app.clearGlobalMessage();
+
+ // Fetch vault from server with the new auth token
+ const vaultResponse = await webApi.authFetch('Vault', {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${result.token}`,
+ },
+ });
+
+ // Store auth tokens and username
+ await app.setAuthTokens(result.username, result.token, result.refreshToken);
+
+ // Store the encryption key and derivation params
+ await dbContext.storeEncryptionKey(result.decryptionKey);
+ await dbContext.storeEncryptionKeyDerivationParams({
+ salt: result.salt,
+ encryptionType: result.encryptionType,
+ encryptionSettings: result.encryptionSettings,
+ });
+
+ // Initialize the database with the vault data
+ const sqliteClient = await dbContext.initializeDatabase(vaultResponse, result.decryptionKey);
+
+ // Check for pending migrations
+ try {
+ if (await sqliteClient.hasPendingMigrations()) {
+ navigate('/upgrade', { replace: true });
+ hideLoading();
+ setIsInitialLoading(false);
+ return;
+ }
+ } catch (err) {
+ await app.logout();
+ setError(err instanceof Error ? err.message : t('common.errors.unknownError'));
+ hideLoading();
+ return;
+ }
+
+ // Navigate to reinitialize page
+ hideLoading();
+ setIsInitialLoading(false);
+ navigate('/reinitialize', { replace: true });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : t('common.errors.unknownError'));
+ hideLoading();
+ throw err; // Re-throw to let modal show error
+ }
+ };
+
/**
* Handle change
*/
@@ -330,7 +390,7 @@ const Login: React.FC = () => {
}}
variant="secondary"
>
- {t('auth.cancel')}
+ {t('common.cancel')}
@@ -342,82 +402,113 @@ const Login: React.FC = () => {
}
return (
-
@if (_loadingError)
diff --git a/apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor b/apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor
new file mode 100644
index 000000000..18044ea16
--- /dev/null
+++ b/apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor
@@ -0,0 +1,382 @@
+@page "/mobile-login-history"
+@using AliasVault.Admin.Main.Models
+@using AliasVault.RazorComponents.Tables
+@using Microsoft.AspNetCore.WebUtilities
+@inject NavigationManager NavigationManager
+@inherits MainBase
+
+Mobile Login History
+
+
+
+
+
+
+
+@if (IsInitialized)
+{
+
+
+
+
+
+
+ All Requests
+ Retrieved
+ Fulfilled
+ Pending
+
+
+
+
+}
+
+@if (IsLoading)
+{
+
+}
+else
+{
+
+
+ @foreach (var request in RequestList)
+ {
+
+ @request.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")
+
+ @(request.ClientIpAddress ?? "N/A")
+
+
+ @(request.MobileIpAddress ?? "N/A")
+
+
+ @if (request.FulfilledAt.HasValue)
+ {
+ @request.FulfilledAt.Value.ToString("yyyy-MM-dd HH:mm:ss")
+ }
+ else
+ {
+ -
+ }
+
+
+ @if (request.RetrievedAt.HasValue)
+ {
+ @request.RetrievedAt.Value.ToString("yyyy-MM-dd HH:mm:ss")
+ }
+ else
+ {
+ -
+ }
+
+
+ @if (!string.IsNullOrEmpty(request.Username))
+ {
+
+ @request.Username
+
+ }
+ else
+ {
+ -
+ }
+
+
+ @if (request.RetrievedAt.HasValue)
+ {
+
+ }
+ else if (request.FulfilledAt.HasValue)
+ {
+
+ }
+ else
+ {
+
+ }
+
+
+ }
+
+
+}
+
+@code {
+ private readonly List _tableColumns = [
+ new TableColumn { Title = "Created At", PropertyName = "CreatedAt" },
+ new TableColumn { Title = "Client IP", PropertyName = "ClientIpAddress" },
+ new TableColumn { Title = "Mobile IP", PropertyName = "MobileIpAddress" },
+ new TableColumn { Title = "Fulfilled At", PropertyName = "FulfilledAt" },
+ new TableColumn { Title = "Retrieved At", PropertyName = "RetrievedAt" },
+ new TableColumn { Title = "Username", PropertyName = "Username" },
+ new TableColumn { Title = "Status", Sortable = false },
+ ];
+
+ private List RequestList { get; set; } = [];
+ private bool IsInitialized { get; set; } = false;
+ private bool IsLoading { get; set; } = true;
+ private int CurrentPage { get; set; } = 1;
+ private int PageSize { get; set; } = 50;
+ private int TotalRecords { get; set; }
+
+ private string _searchTerm = string.Empty;
+ private CancellationTokenSource? _searchCancellationTokenSource;
+ private string _lastSearchTerm = string.Empty;
+
+ private string SearchTerm
+ {
+ get => _searchTerm;
+ set
+ {
+ if (_searchTerm != value)
+ {
+ _searchTerm = value;
+ _searchCancellationTokenSource?.Cancel();
+ _searchCancellationTokenSource = new CancellationTokenSource();
+ _ = RefreshData(_searchCancellationTokenSource.Token);
+ }
+ }
+ }
+
+ private string _selectedStatusFilter = string.Empty;
+ private string _lastSelectedStatusFilter = string.Empty;
+ private string SelectedStatusFilter
+ {
+ get => _selectedStatusFilter;
+ set
+ {
+ if (_selectedStatusFilter != value)
+ {
+ _selectedStatusFilter = value;
+ _searchCancellationTokenSource?.Cancel();
+ _searchCancellationTokenSource = new CancellationTokenSource();
+ _ = RefreshData(_searchCancellationTokenSource.Token);
+ }
+ }
+ }
+
+ private string SortColumn { get; set; } = "CreatedAt";
+ private SortDirection SortDirection { get; set; } = SortDirection.Descending;
+
+ private async Task HandleSortChanged((string column, SortDirection direction) sort)
+ {
+ SortColumn = sort.column;
+ SortDirection = sort.direction;
+ await RefreshData(CancellationToken.None);
+ }
+
+ protected override async Task OnInitializedAsync()
+ {
+ await base.OnInitializedAsync();
+ BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Users", Url = "users" });
+ BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Mobile Login History" });
+
+ // Check for search query parameter
+ var uri = new Uri(NavigationManager.Uri);
+ var queryParams = QueryHelpers.ParseQuery(uri.Query);
+ if (queryParams.TryGetValue("search", out var search))
+ {
+ _searchTerm = search.ToString();
+ }
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await RefreshData(CancellationToken.None);
+ }
+ }
+
+ private void HandlePageChanged(int newPage)
+ {
+ CurrentPage = newPage;
+ _ = RefreshData(CancellationToken.None);
+ }
+
+ private async Task RefreshData(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ IsLoading = true;
+ StateHasChanged();
+
+ await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken);
+
+ // Join with AliasVaultUsers table to get username via UserId FK
+ var query = from request in dbContext.MobileLoginRequests
+ join user in dbContext.AliasVaultUsers on request.UserId equals user.Id into userJoin
+ from user in userJoin.DefaultIfEmpty()
+ select new MobileLoginRequestWithUsername
+ {
+ Request = request,
+ Username = user != null ? user.UserName : null
+ };
+
+ query = ApplySearchFilter(query);
+ query = ApplyStatusFilter(query);
+ query = ApplySort(query);
+
+ TotalRecords = await query.CountAsync(cancellationToken);
+ var requests = await query
+ .Skip((CurrentPage - 1) * PageSize)
+ .Take(PageSize)
+ .Select(r => new
+ {
+ r.Request.CreatedAt,
+ r.Request.ClientIpAddress,
+ r.Request.MobileIpAddress,
+ r.Request.FulfilledAt,
+ r.Request.RetrievedAt,
+ r.Username,
+ r.Request.UserId
+ })
+ .ToListAsync(cancellationToken);
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ RequestList = requests.Select(r => new MobileLoginRequestModel
+ {
+ CreatedAt = r.CreatedAt,
+ ClientIpAddress = r.ClientIpAddress,
+ MobileIpAddress = r.MobileIpAddress,
+ FulfilledAt = r.FulfilledAt,
+ RetrievedAt = r.RetrievedAt,
+ Username = r.Username,
+ UserId = r.UserId
+ }).ToList();
+
+ IsLoading = false;
+ IsInitialized = true;
+ StateHasChanged();
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when cancellation is requested, do nothing
+ }
+ }
+
+ private IQueryable ApplySearchFilter(IQueryable query)
+ {
+ if (SearchTerm.Length > 0)
+ {
+ // Reset page number back to 1 if the search term has changed
+ if (SearchTerm != _lastSearchTerm && CurrentPage != 1)
+ {
+ CurrentPage = 1;
+ }
+ _lastSearchTerm = SearchTerm;
+
+ var searchTerm = SearchTerm.Trim().ToLower();
+ query = query.Where(r =>
+ (r.Username != null && EF.Functions.Like(r.Username.ToLower(), "%" + searchTerm + "%")) ||
+ (r.Request.ClientIpAddress != null && EF.Functions.Like(r.Request.ClientIpAddress.ToLower(), "%" + searchTerm + "%")) ||
+ (r.Request.MobileIpAddress != null && EF.Functions.Like(r.Request.MobileIpAddress.ToLower(), "%" + searchTerm + "%"))
+ );
+ }
+
+ return query;
+ }
+
+ private IQueryable ApplyStatusFilter(IQueryable query)
+ {
+ if (!string.IsNullOrEmpty(SelectedStatusFilter))
+ {
+ // Reset page number back to 1 if the filter has changed
+ if (SelectedStatusFilter != _lastSelectedStatusFilter && CurrentPage != 1)
+ {
+ CurrentPage = 1;
+ }
+ _lastSelectedStatusFilter = SelectedStatusFilter;
+
+ switch (SelectedStatusFilter)
+ {
+ case "retrieved":
+ query = query.Where(r => r.Request.RetrievedAt != null);
+ break;
+ case "fulfilled":
+ query = query.Where(r => r.Request.FulfilledAt != null && r.Request.RetrievedAt == null);
+ break;
+ case "pending":
+ query = query.Where(r => r.Request.FulfilledAt == null);
+ break;
+ }
+ }
+
+ return query;
+ }
+
+ private IQueryable ApplySort(IQueryable query)
+ {
+ switch (SortColumn)
+ {
+ case "CreatedAt":
+ query = SortDirection == SortDirection.Ascending
+ ? query.OrderBy(x => x.Request.CreatedAt)
+ : query.OrderByDescending(x => x.Request.CreatedAt);
+ break;
+ case "ClientIpAddress":
+ query = SortDirection == SortDirection.Ascending
+ ? query.OrderBy(x => x.Request.ClientIpAddress)
+ : query.OrderByDescending(x => x.Request.ClientIpAddress);
+ break;
+ case "MobileIpAddress":
+ query = SortDirection == SortDirection.Ascending
+ ? query.OrderBy(x => x.Request.MobileIpAddress)
+ : query.OrderByDescending(x => x.Request.MobileIpAddress);
+ break;
+ case "FulfilledAt":
+ query = SortDirection == SortDirection.Ascending
+ ? query.OrderBy(x => x.Request.FulfilledAt)
+ : query.OrderByDescending(x => x.Request.FulfilledAt);
+ break;
+ case "RetrievedAt":
+ query = SortDirection == SortDirection.Ascending
+ ? query.OrderBy(x => x.Request.RetrievedAt)
+ : query.OrderByDescending(x => x.Request.RetrievedAt);
+ break;
+ case "Username":
+ query = SortDirection == SortDirection.Ascending
+ ? query.OrderBy(x => x.Username)
+ : query.OrderByDescending(x => x.Username);
+ break;
+ default:
+ query = query.OrderByDescending(x => x.Request.CreatedAt);
+ break;
+ }
+
+ return query;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _searchCancellationTokenSource?.Cancel();
+ _searchCancellationTokenSource?.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ public class MobileLoginRequestModel
+ {
+ public DateTime CreatedAt { get; set; }
+ public string? ClientIpAddress { get; set; }
+ public string? MobileIpAddress { get; set; }
+ public DateTime? FulfilledAt { get; set; }
+ public DateTime? RetrievedAt { get; set; }
+ public string? Username { get; set; }
+ public string? UserId { get; set; }
+ }
+}
diff --git a/apps/server/AliasVault.Admin/Main/Pages/Settings/Server.razor b/apps/server/AliasVault.Admin/Main/Pages/Settings/Server.razor
index 3e964af90..a19dee2e8 100644
--- a/apps/server/AliasVault.Admin/Main/Pages/Settings/Server.razor
+++ b/apps/server/AliasVault.Admin/Main/Pages/Settings/Server.razor
@@ -48,6 +48,12 @@
Number of days to keep auth logs before deletion. Set to 0 to disable automatic cleanup.
+
+
Mobile Login Log Retention (days)
+
+
+ Number of days to keep mobile login request logs before deletion. Set to 0 to disable automatic cleanup.
+
diff --git a/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor b/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor
index f46cf9110..13d1c5df0 100644
--- a/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor
+++ b/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor
@@ -11,6 +11,12 @@
Title="@(TotalRecords > 0 ? $"Users ({TotalRecords:N0})" : "Users")"
Description="This page shows an overview of all registered users and the associated vaults.">
+
+
+
+
+ Mobile Login History
+
diff --git a/apps/server/AliasVault.Admin/Services/StatisticsService.cs b/apps/server/AliasVault.Admin/Services/StatisticsService.cs
index 94f033148..236e24acc 100644
--- a/apps/server/AliasVault.Admin/Services/StatisticsService.cs
+++ b/apps/server/AliasVault.Admin/Services/StatisticsService.cs
@@ -115,6 +115,7 @@ public class StatisticsService
GetTopUsersByAliases72hAsync().ContinueWith(t => stats.TopUsersByAliases72h = t.Result),
GetTopUsersByEmails72hAsync().ContinueWith(t => stats.TopUsersByEmails72h = t.Result),
GetTopIpsByRegistrations72hAsync().ContinueWith(t => stats.TopIpsByRegistrations72h = t.Result),
+ GetTopIpsByMobileLogins72hAsync().ContinueWith(t => stats.TopIpsByMobileLogins72h = t.Result),
};
await Task.WhenAll(tasks);
@@ -570,4 +571,36 @@ public class StatisticsService
RegistrationCount72h = ip.RegistrationCount72h,
}).ToList();
}
+
+ ///
+ /// Gets the top 20 IP addresses by number of mobile login requests in the last 72 hours.
+ ///
+ /// List of top IP addresses by mobile login requests.
+ private async Task> GetTopIpsByMobileLogins72hAsync()
+ {
+ await using var context = await _contextFactory.CreateDbContextAsync();
+ var cutoffDate = DateTime.UtcNow.AddHours(-72);
+
+ // Get mobile login requests by client IP
+ var topIps = await context.MobileLoginRequests
+ .Where(mlr => mlr.CreatedAt >= cutoffDate &&
+ mlr.ClientIpAddress != null &&
+ mlr.ClientIpAddress != "xxx.xxx.xxx.xxx")
+ .GroupBy(mlr => mlr.ClientIpAddress)
+ .Select(g => new
+ {
+ IpAddress = g.Key,
+ MobileLoginCount72h = g.Count(),
+ })
+ .OrderByDescending(ip => ip.MobileLoginCount72h)
+ .Take(20)
+ .ToListAsync();
+
+ return topIps.Select(ip => new RecentUsageMobileLogins
+ {
+ OriginalIpAddress = ip.IpAddress!,
+ IpAddress = AnonymizeIpAddress(ip.IpAddress!),
+ MobileLoginCount72h = ip.MobileLoginCount72h,
+ }).ToList();
+ }
}
diff --git a/apps/server/AliasVault.Api/Controllers/AuthController.cs b/apps/server/AliasVault.Api/Controllers/AuthController.cs
index 3be6e48f4..6c2f51a3d 100644
--- a/apps/server/AliasVault.Api/Controllers/AuthController.cs
+++ b/apps/server/AliasVault.Api/Controllers/AuthController.cs
@@ -15,6 +15,7 @@ using AliasServerDb;
using AliasVault.Api.Helpers;
using AliasVault.Auth;
using AliasVault.Cryptography.Client;
+using AliasVault.Cryptography.Server;
using AliasVault.Shared.Core;
using AliasVault.Shared.Models.Enums;
using AliasVault.Shared.Models.WebApi;
@@ -49,6 +50,23 @@ using SecureRemotePassword;
[ApiVersion("1")]
public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserManager userManager, SignInManager signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider, AuthLoggingService authLoggingService, Config config, ServerSettingsService settingsService) : ControllerBase
{
+ ///
+ /// Timeout in minutes for mobile login requests. Clients use 2 minutes for countdown, we use 3 here to give a bit of extra buffer time.
+ /// Requests older than this will be automatically expired and removed.
+ ///
+ private const int MobileLoginTimeoutMinutes = 10;
+
+ ///
+ /// Access token validity in minutes.
+ ///
+ ///
+ /// This is the time period for which the access token is valid.
+ /// It is used to authenticate the user for a limited time
+ /// and is short-lived by design. With the separate refresh token, the user can request a new access token
+ /// when this access token expires.
+ ///
+ private const int AccessTokenValiditySeconds = 600;
+
///
/// Semaphore to prevent concurrent access to the database when generating new tokens for a user.
///
@@ -533,6 +551,204 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
latestVaultEncryptionSettings.EncryptionSettings));
}
+ ///
+ /// Initiates a mobile login request by creating a QR code challenge.
+ ///
+ /// The mobile login initiate request model.
+ /// IActionResult.
+ [HttpPost("mobile-login/initiate")]
+ [AllowAnonymous]
+ public async Task InitiateMobileLogin([FromBody] MobileLoginInitiateRequest model)
+ {
+ await using var context = await dbContextFactory.CreateDbContextAsync();
+
+ // Generate a unique request ID
+ var requestId = Guid.NewGuid().ToString("N");
+
+ // Create the login request
+ var loginRequest = new MobileLoginRequest
+ {
+ Id = requestId,
+ ClientPublicKey = model.ClientPublicKey,
+ CreatedAt = timeProvider.UtcNow,
+ ClientIpAddress = IpAddressUtility.GetIpFromContext(HttpContext),
+ };
+
+ context.MobileLoginRequests.Add(loginRequest);
+ await context.SaveChangesAsync();
+
+ return Ok(new MobileLoginInitiateResponse
+ {
+ RequestId = requestId,
+ });
+ }
+
+ ///
+ /// Polls the status of a mobile login request.
+ ///
+ /// The unique identifier for the login request.
+ /// IActionResult.
+ [HttpGet("mobile-login/poll/{requestId}")]
+ [AllowAnonymous]
+ public async Task PollMobileLogin(string requestId)
+ {
+ await using var context = await dbContextFactory.CreateDbContextAsync();
+
+ var loginRequest = await context.MobileLoginRequests.FirstOrDefaultAsync(r => r.Id == requestId);
+
+ // Check if request exists and hasn't expired
+ if (loginRequest == null || loginRequest.CreatedAt.AddMinutes(MobileLoginTimeoutMinutes) < timeProvider.UtcNow)
+ {
+ return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404));
+ }
+
+ // If not fulfilled, return pending status
+ if (loginRequest.FulfilledAt == null)
+ {
+ return Ok(new MobileLoginPollResponse
+ {
+ Fulfilled = false,
+ EncryptedSymmetricKey = null,
+ EncryptedToken = null,
+ EncryptedRefreshToken = null,
+ EncryptedDecryptionKey = null,
+ EncryptedUsername = null,
+ });
+ }
+
+ // Check if already retrieved (one-time use protection)
+ if (loginRequest.RetrievedAt != null)
+ {
+ return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404));
+ }
+
+ // Sanity check: check if user exists using UserId FK
+ var user = await userManager.FindByIdAsync(loginRequest.UserId!);
+ if (user == null)
+ {
+ await authLoggingService.LogAuthEventFailAsync("n/a", AuthEventType.MobileLogin, AuthFailureReason.InvalidUsername);
+ return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND, 400));
+ }
+
+ // Sanity check: check if the account is blocked.
+ if (user.Blocked)
+ {
+ await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.MobileLogin, AuthFailureReason.AccountBlocked);
+ return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.ACCOUNT_BLOCKED, 400));
+ }
+
+ // Sanity check: check if the account is locked out.
+ if (await userManager.IsLockedOutAsync(user))
+ {
+ await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.MobileLogin, AuthFailureReason.AccountLocked);
+ return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.ACCOUNT_LOCKED, 400));
+ }
+
+ // Generate token for the user
+ var tokenModel = await GenerateNewTokensForUser(user, extendedLifetime: true);
+
+ // Get encrypted decryption key from the login request and put it in memory
+ var encryptedDecryptionKey = loginRequest.EncryptedDecryptionKey!;
+
+ // Generate a single symmetric key for encrypting all fields
+ var symmetricKey = Cryptography.Server.Encryption.GenerateRandomSymmetricKey();
+
+ // Encrypt each field with the symmetric key (returns base64)
+ var encryptedToken = Cryptography.Server.Encryption.SymmetricEncrypt(tokenModel.Token, symmetricKey);
+ var encryptedRefreshToken = Cryptography.Server.Encryption.SymmetricEncrypt(tokenModel.RefreshToken, symmetricKey);
+ var encryptedUsername = Cryptography.Server.Encryption.SymmetricEncrypt(user.UserName!, symmetricKey);
+
+ // Encrypt the symmetric key with the client's RSA public key (returns base64)
+ var encryptedSymmetricKey = Cryptography.Server.Encryption.EncryptSymmetricKeyWithRsa(symmetricKey, loginRequest.ClientPublicKey);
+
+ // Log successful mobile login authentication
+ await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.MobileLogin);
+
+ // Mark as retrieved and clear sensitive data from database
+ loginRequest.ClientPublicKey = string.Empty;
+ loginRequest.EncryptedDecryptionKey = null;
+ loginRequest.RetrievedAt = timeProvider.UtcNow;
+ await context.SaveChangesAsync();
+
+ // Return response with encrypted symmetric key and encrypted fields
+ // Client will decrypt username to call /login endpoint for salt and encryption settings
+ return Ok(new MobileLoginPollResponse
+ {
+ Fulfilled = true,
+ EncryptedSymmetricKey = encryptedSymmetricKey,
+ EncryptedToken = encryptedToken,
+ EncryptedRefreshToken = encryptedRefreshToken,
+ EncryptedDecryptionKey = encryptedDecryptionKey,
+ EncryptedUsername = encryptedUsername,
+ });
+ }
+
+ ///
+ /// Gets the public key for a mobile login request (for mobile app to encrypt).
+ ///
+ /// The unique identifier for the login request.
+ /// IActionResult.
+ [HttpGet("mobile-login/request/{requestId}")]
+ [Authorize]
+ public async Task GetMobileLoginRequest(string requestId)
+ {
+ await using var context = await dbContextFactory.CreateDbContextAsync();
+
+ var loginRequest = await context.MobileLoginRequests.FirstOrDefaultAsync(r => r.Id == requestId);
+
+ // Check if request exists and hasn't expired
+ if (loginRequest == null || loginRequest.CreatedAt.AddMinutes(MobileLoginTimeoutMinutes) < timeProvider.UtcNow)
+ {
+ return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404));
+ }
+
+ // Return only the public key
+ return Ok(new { clientPublicKey = loginRequest.ClientPublicKey });
+ }
+
+ ///
+ /// Submits a mobile login response from the mobile app.
+ ///
+ /// The mobile login submit request model.
+ /// IActionResult.
+ [HttpPost("mobile-login/submit")]
+ [Authorize]
+ public async Task SubmitMobileLogin([FromBody] MobileLoginSubmitRequest model)
+ {
+ await using var context = await dbContextFactory.CreateDbContextAsync();
+
+ // Get the authenticated user
+ var user = await userManager.GetUserAsync(User);
+ if (user == null)
+ {
+ return Unauthorized(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.USER_NOT_FOUND, 401));
+ }
+
+ var loginRequest = await context.MobileLoginRequests.FirstOrDefaultAsync(r => r.Id == model.RequestId);
+
+ // Check if request exists and hasn't expired
+ if (loginRequest == null || loginRequest.CreatedAt.AddMinutes(MobileLoginTimeoutMinutes) < timeProvider.UtcNow)
+ {
+ return NotFound(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_NOT_FOUND, 404));
+ }
+
+ // Check if already fulfilled
+ if (loginRequest.FulfilledAt != null)
+ {
+ return BadRequest(ApiErrorCodeHelper.CreateErrorResponse(ApiErrorCode.MOBILE_LOGIN_REQUEST_ALREADY_FULFILLED, 400));
+ }
+
+ // Update the login request with the encrypted key and user ID
+ loginRequest.EncryptedDecryptionKey = model.EncryptedDecryptionKey;
+ loginRequest.UserId = user.Id;
+ loginRequest.FulfilledAt = timeProvider.UtcNow;
+ loginRequest.MobileIpAddress = IpAddressUtility.GetIpFromContext(HttpContext);
+
+ await context.SaveChangesAsync();
+
+ return Ok();
+ }
+
///
/// Confirms the account deletion process.
///
@@ -764,7 +980,7 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
issuer: configuration["Jwt:Issuer"] ?? string.Empty,
audience: configuration["Jwt:Issuer"] ?? string.Empty,
claims: claims,
- expires: timeProvider.UtcNow.AddMinutes(10),
+ expires: timeProvider.UtcNow.AddSeconds(AccessTokenValiditySeconds),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
diff --git a/apps/server/AliasVault.Api/Helpers/AuthHelper.cs b/apps/server/AliasVault.Api/Helpers/AuthHelper.cs
index 3b90237f7..180e6da3b 100644
--- a/apps/server/AliasVault.Api/Helpers/AuthHelper.cs
+++ b/apps/server/AliasVault.Api/Helpers/AuthHelper.cs
@@ -77,9 +77,13 @@ public static class AuthHelper
/// Generate a device identifier based on request headers. This is used to associate refresh tokens
/// with a specific device for a specific user.
///
+ /// The identifier includes the client type (web app, browser extension, mobile app) to prevent
+ /// conflicts when a user is logged in on multiple clients from the same browser/device.
+ /// For example, logging out from the browser extension won't affect the web app session.
+ ///
/// NOTE: current implementation means that only one refresh token can be valid for a
/// specific user/device combo at a time. The identifier generation could be made more unique in the future
- /// to prevent any unwanted conflicts.
+ /// to prevent any potential unwanted conflicts.
///
/// The HttpRequest instance for the request that the client used.
/// Unique device identifier as string.
@@ -88,7 +92,11 @@ public static class AuthHelper
var userAgent = request.Headers.UserAgent.ToString();
var acceptLanguage = request.Headers.AcceptLanguage.ToString();
- var rawIdentifier = $"{userAgent}|{acceptLanguage}";
+ // Client header is usually formatted like "[client name]-[version]" e.g. "chrome-0.25.0", take only "chrome"
+ var clientHeader = request.Headers["X-AliasVault-Client"].ToString();
+ var clientName = clientHeader?.Split('-')[0] ?? "unknown";
+
+ var rawIdentifier = $"{clientName}|{userAgent}|{acceptLanguage}";
return rawIdentifier;
}
}
diff --git a/apps/server/AliasVault.Client/Auth/Components/MobileUnlockModal.razor b/apps/server/AliasVault.Client/Auth/Components/MobileUnlockModal.razor
new file mode 100644
index 000000000..ced3ed2c0
--- /dev/null
+++ b/apps/server/AliasVault.Client/Auth/Components/MobileUnlockModal.razor
@@ -0,0 +1,319 @@
+@using AliasVault.Client.Auth.Models
+@using AliasVault.Client.Auth.Services
+@using AliasVault.Client.Main.Components.Layout
+@using AliasVault.Client.Utilities
+@using Microsoft.Extensions.Localization
+@using Microsoft.Extensions.DependencyInjection
+@implements IDisposable
+
+@if (IsOpen)
+{
+
+
+
+
+
+
+
+
+ @SharedLocalizer["Close"]
+
+
+
+
+
+
+
+
+ @Title
+
+
+ @Description
+
+
+ @if (!string.IsNullOrEmpty(_errorMessage))
+ {
+
+ @_errorMessage
+
+ }
+
+ @if (!string.IsNullOrEmpty(_qrCodeUrl))
+ {
+
+
+
+
+ @if (!_isLoading)
+ {
+
+ @FormatTime(_timeRemaining)
+
+ }
+
+ }
+
+ @if (_isLoading && string.IsNullOrEmpty(_errorMessage))
+ {
+
+ }
+
+
+ @SharedLocalizer["Cancel"]
+
+
+
+
+}
+
+@code {
+ private string? _qrCodeUrl;
+ private MobileLoginErrorCode? _errorCode;
+ private string? _errorMessage;
+ private int _timeRemaining = 120; // 2 minutes in seconds
+ private MobileLoginUtility? _mobileLoginUtility;
+ private bool _isLoading = true;
+ private System.Threading.Timer? _countdownTimer;
+ private string _qrElementId = $"mobile-unlock-qr-{Guid.NewGuid():N}";
+
+ [Inject]
+ private HttpClient Http { get; set; } = default!;
+
+ [Inject]
+ private JsInteropService JsInteropService { get; set; } = default!;
+
+ [Inject]
+ private ILogger Logger { get; set; } = default!;
+
+ [Inject]
+ private IStringLocalizerFactory LocalizerFactory { get; set; } = default!;
+
+ [Inject]
+ private IServiceProvider ScopedServices { get; set; } = default!;
+
+ private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client");
+
+ private IStringLocalizer MobileLoginLocalizer => LocalizerFactory.Create("MobileLogin", "AliasVault.Client");
+
+ ///
+ /// Whether the modal is open.
+ ///
+ [Parameter]
+ public bool IsOpen { get; set; }
+
+ ///
+ /// Callback when the modal is closed.
+ ///
+ [Parameter]
+ public EventCallback OnClose { get; set; }
+
+ ///
+ /// Callback when mobile login/unlock succeeds with the result.
+ ///
+ [Parameter]
+ public EventCallback OnSuccess { get; set; }
+
+ ///
+ /// Mode - 'login' or 'unlock'.
+ ///
+ [Parameter]
+ public string Mode { get; set; } = "login";
+
+ ///
+ /// Modal title based on mode.
+ ///
+ private string Title => Mode == "unlock"
+ ? LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["UnlockTitle"]
+ : LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["PageTitle"];
+
+ ///
+ /// Modal description based on mode.
+ ///
+ private string Description => LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["ScanQrCodeDescription"];
+
+ ///
+ protected override async Task OnParametersSetAsync()
+ {
+ await base.OnParametersSetAsync();
+
+ if (IsOpen && _mobileLoginUtility == null)
+ {
+ await InitiateMobileLoginAsync();
+ }
+ else if (!IsOpen)
+ {
+ Cleanup();
+ }
+ }
+
+ ///
+ /// Initialize mobile login when modal opens.
+ ///
+ private async Task InitiateMobileLoginAsync()
+ {
+ try
+ {
+ _isLoading = true;
+ _errorCode = null;
+ _errorMessage = null;
+ _qrCodeUrl = null;
+ _timeRemaining = 120;
+ StateHasChanged();
+
+ // Initialize mobile login utility
+ var utilityLogger = ScopedServices.GetRequiredService>();
+ _mobileLoginUtility = new MobileLoginUtility(Http, JsInteropService, utilityLogger);
+
+ // Initiate mobile login and get QR code data
+ var requestId = await _mobileLoginUtility.InitiateAsync();
+
+ // Generate QR code with AliasVault prefix for mobile login
+ _qrCodeUrl = $"aliasvault://open/mobile-unlock/{requestId}";
+
+ // Render QR code while showing loading
+ StateHasChanged();
+ await Task.Delay(100); // Give DOM time to render
+ await JsInteropService.GenerateQrCode(_qrElementId);
+
+ // Wait for QR code to be fully rendered before hiding loading
+ await Task.Delay(300);
+
+ _isLoading = false;
+ StateHasChanged();
+
+ // Start countdown timer
+ StartCountdownTimer();
+
+ // Start polling for response
+ await _mobileLoginUtility.StartPollingAsync(
+ HandleSuccessAsync,
+ HandleErrorAsync);
+ }
+ catch (MobileLoginException ex)
+ {
+ Logger.LogError(ex, "Error initiating mobile login");
+ _isLoading = false;
+ _errorCode = ex.ErrorCode;
+ _errorMessage = GetErrorMessage(ex.ErrorCode);
+ StateHasChanged();
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error initiating mobile login");
+ _isLoading = false;
+ _errorCode = MobileLoginErrorCode.Generic;
+ _errorMessage = GetErrorMessage(MobileLoginErrorCode.Generic);
+ StateHasChanged();
+ }
+ }
+
+ ///
+ /// Handle successful mobile login/unlock.
+ ///
+ private async Task HandleSuccessAsync(MobileLoginResult result)
+ {
+ try
+ {
+ // Call parent success callback
+ await OnSuccess.InvokeAsync(result);
+
+ // Close modal after successful processing
+ await HandleClose();
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error handling mobile login success");
+ _errorMessage = SharedLocalizer["ErrorUnknown"];
+ StateHasChanged();
+ }
+ }
+
+ ///
+ /// Handle error.
+ ///
+ private void HandleErrorAsync(MobileLoginErrorCode errorCode)
+ {
+ _isLoading = false;
+ _qrCodeUrl = null; // Hide QR code when error occurs
+ _errorCode = errorCode;
+ _errorMessage = GetErrorMessage(errorCode);
+ StateHasChanged();
+ }
+
+ ///
+ /// Get translated error message for error code.
+ ///
+ private string GetErrorMessage(MobileLoginErrorCode errorCode)
+ {
+ return errorCode switch
+ {
+ MobileLoginErrorCode.Timeout => MobileLoginLocalizer["ErrorTimeout"],
+ MobileLoginErrorCode.Generic => SharedLocalizer["ErrorUnknown"],
+ _ => SharedLocalizer["ErrorUnknown"]
+ };
+ }
+
+ ///
+ /// Handle modal close.
+ ///
+ private async Task HandleClose()
+ {
+ Cleanup();
+ await OnClose.InvokeAsync();
+ }
+
+ ///
+ /// Cleanup resources.
+ ///
+ private void Cleanup()
+ {
+ _mobileLoginUtility?.Cleanup();
+ _mobileLoginUtility?.Dispose();
+ _mobileLoginUtility = null;
+ _countdownTimer?.Dispose();
+ _countdownTimer = null;
+ _qrCodeUrl = null;
+ _errorCode = null;
+ _errorMessage = null;
+ _timeRemaining = 120;
+ _isLoading = true;
+ }
+
+ ///
+ /// Format time remaining as MM:SS.
+ ///
+ private string FormatTime(int seconds)
+ {
+ var mins = seconds / 60;
+ var secs = seconds % 60;
+ return $"{mins}:{secs:D2}";
+ }
+
+ ///
+ /// Start countdown timer.
+ ///
+ private void StartCountdownTimer()
+ {
+ _countdownTimer = new System.Threading.Timer(_ =>
+ {
+ if (_timeRemaining > 0)
+ {
+ _timeRemaining--;
+ InvokeAsync(StateHasChanged);
+ }
+ else
+ {
+ _countdownTimer?.Dispose();
+ _mobileLoginUtility?.StopPolling();
+ }
+ }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
+ }
+
+ ///
+ public void Dispose()
+ {
+ Cleanup();
+ }
+}
diff --git a/apps/server/AliasVault.Client/Auth/Models/MobileLoginResult.cs b/apps/server/AliasVault.Client/Auth/Models/MobileLoginResult.cs
new file mode 100644
index 000000000..bd11e42ea
--- /dev/null
+++ b/apps/server/AliasVault.Client/Auth/Models/MobileLoginResult.cs
@@ -0,0 +1,34 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) aliasvault. All rights reserved.
+// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
+//
+//-----------------------------------------------------------------------
+
+namespace AliasVault.Client.Auth.Models;
+
+///
+/// Result of a successful mobile login containing decrypted authentication data.
+///
+public sealed class MobileLoginResult
+{
+ ///
+ /// Gets or sets the username.
+ ///
+ public required string Username { get; set; }
+
+ ///
+ /// Gets or sets the JWT access token.
+ ///
+ public required string Token { get; set; }
+
+ ///
+ /// Gets or sets the refresh token.
+ ///
+ public required string RefreshToken { get; set; }
+
+ ///
+ /// Gets or sets the vault decryption key (base64 encoded).
+ ///
+ public required string DecryptionKey { get; set; }
+}
diff --git a/apps/server/AliasVault.Client/Auth/Pages/Login.razor b/apps/server/AliasVault.Client/Auth/Pages/Login.razor
index 52efae0ca..f7ca8fcb8 100644
--- a/apps/server/AliasVault.Client/Auth/Pages/Login.razor
+++ b/apps/server/AliasVault.Client/Auth/Pages/Login.razor
@@ -112,10 +112,22 @@ else
@Localizer["LostPasswordLink"]
- @Localizer["LoginToAccountButton"]
+
+
+ @Localizer["LoginButton"]
+
+
_showMobileLoginModal = true" class="hidden md:flex w-full px-5 py-2 text-base font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600 dark:focus:ring-gray-700 items-center justify-center gap-2">
+
+
+
+ @Localizer["MobileDeviceLink"]
+
+
+
+
@if (Config.PublicRegistrationEnabled)
{
-
+
}
@@ -124,6 +136,11 @@ else
+
+
@code {
private readonly LoginFormModel _loginModel = new();
private readonly LoginModel2Fa _loginModel2Fa = new();
@@ -132,9 +149,11 @@ else
private ServerValidationErrors _serverValidationErrors = new();
private bool _showTwoFactorAuthStep;
private bool _showLoginWithRecoveryCodeStep;
+ private bool _showMobileLoginModal;
private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Auth.Login", "AliasVault.Client");
private IStringLocalizer ApiErrorLocalizer => LocalizerFactory.Create("ApiErrors", "AliasVault.Client");
+ private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client");
private SrpEphemeral _clientEphemeral = new();
private SrpSession _clientSession = new();
@@ -455,4 +474,78 @@ else
}
}
+ ///
+ /// Handle successful mobile login.
+ ///
+ private async Task HandleMobileLoginSuccess(MobileLoginResult result)
+ {
+ _loadingIndicator.Show(Localizer["LoggingInMessage"]);
+ _serverValidationErrors.Clear();
+
+ try
+ {
+ // Clear global messages
+ GlobalNotificationService.ClearMessages();
+
+ // Call /login endpoint to retrieve salt and encryption settings
+ var loginInitiateRequest = new { username = result.Username };
+ var loginResponse = await Http.PostAsJsonAsync("v1/Auth/login", loginInitiateRequest);
+
+ if (!loginResponse.IsSuccessStatusCode)
+ {
+ _serverValidationErrors.AddError(Localizer["LoginErrorMessage"]);
+ return;
+ }
+
+ var loginData = await loginResponse.Content.ReadFromJsonAsync
();
+ if (loginData == null)
+ {
+ _serverValidationErrors.AddError(Localizer["LoginErrorMessage"]);
+ return;
+ }
+
+ // Store the tokens in local storage
+ await AuthService.StoreAccessTokenAsync(result.Token);
+ await AuthService.StoreRefreshTokenAsync(result.RefreshToken);
+
+ // Convert decryption key from base64 string to byte array
+ var decryptionKeyBytes = Convert.FromBase64String(result.DecryptionKey);
+
+ // Store the encryption key in memory
+ await AuthService.StoreEncryptionKeyAsync(decryptionKeyBytes);
+
+ await AuthStateProvider.GetAuthenticationStateAsync();
+ GlobalNotificationService.ClearMessages();
+
+ // Redirect to the page the user was trying to access before if set
+ var localStorageReturnUrl = await LocalStorage.GetItemAsync(ReturnUrlKey);
+ if (!string.IsNullOrEmpty(localStorageReturnUrl))
+ {
+ await LocalStorage.RemoveItemAsync(ReturnUrlKey);
+ NavigationManager.NavigateTo(localStorageReturnUrl);
+ }
+ else
+ {
+ NavigationManager.NavigateTo("/");
+ }
+ }
+#if DEBUG
+ catch (Exception ex)
+ {
+ // If in debug mode show the actual exception.
+ _serverValidationErrors.AddError(ex.ToString());
+ }
+#else
+ catch
+ {
+ // If in release mode show a generic error.
+ _serverValidationErrors.AddError(Localizer["LoginErrorMessage"]);
+ }
+#endif
+ finally
+ {
+ _loadingIndicator.Hide();
+ }
+ }
+
}
diff --git a/apps/server/AliasVault.Client/Auth/Pages/Unlock.razor b/apps/server/AliasVault.Client/Auth/Pages/Unlock.razor
index 6933c27f5..9f5d38d50 100644
--- a/apps/server/AliasVault.Client/Auth/Pages/Unlock.razor
+++ b/apps/server/AliasVault.Client/Auth/Pages/Unlock.razor
@@ -46,11 +46,11 @@ else
-
+
@Localizer["UnlockWithWebAuthn"]
- await ShowPasswordLogin()" class="inline-flex items-center justify-center px-5 py-3 text-base font-medium text-center text-gray-900 rounded-lg border border-gray-300 hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 dark:text-white dark:border-gray-700 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
+ await ShowPasswordLogin()" class="inline-flex items-center justify-center px-5 py-2 text-base font-medium text-center text-gray-900 rounded-lg border border-gray-300 hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 dark:text-white dark:border-gray-700 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
@Localizer["UnlockWithPassword"]
@@ -72,20 +72,31 @@ else
-
-
+
@Localizer["UnlockButton"]
+
+ _showMobileUnlockModal = true" class="hidden md:flex w-full px-5 py-2 text-base font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:hover:bg-gray-600 dark:focus:ring-gray-700 items-center justify-center gap-2 mt-4">
+
+
+
+ @Localizer["UnlockWithMobileButton"]
+
}
-
+
@Localizer["SwitchAccountsText"]
@Localizer["LogOutLink"]
}
+
+
@code {
///
/// Skip automatic WebAuthn unlock during page load if set to true.
@@ -98,12 +109,14 @@ else
private bool IsWebAuthnLoading { get; set; }
private bool ShowWebAuthnButton { get; set; }
private bool IsPasswordFocused { get; set; }
+ private bool _showMobileUnlockModal;
private readonly UnlockModel _unlockModel = new();
private FullScreenLoadingIndicator _loadingIndicator = new();
private ServerValidationErrors _serverValidationErrors = new();
private PasswordInputField? passwordField;
private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Auth.Unlock", "AliasVault.Client");
private IStringLocalizer ApiErrorLocalizer => LocalizerFactory.Create("ApiErrors", "AliasVault.Client");
+ private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client");
///
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -353,4 +366,62 @@ else
// Do nothing
}
}
+
+ ///
+ /// Handle successful mobile unlock.
+ ///
+ private async Task HandleMobileUnlockSuccess(MobileLoginResult result)
+ {
+ _loadingIndicator.Show(Localizer["UnlockingVaultMessage"]);
+ _serverValidationErrors.Clear();
+
+ try
+ {
+ await StatusCheck();
+
+ // Revoke existing tokens
+ await AuthService.RemoveTokensAsync();
+
+ // Store the new tokens
+ await AuthService.StoreAccessTokenAsync(result.Token);
+ await AuthService.StoreRefreshTokenAsync(result.RefreshToken);
+
+ // Convert decryption key from base64 string to byte array
+ var decryptionKeyBytes = Convert.FromBase64String(result.DecryptionKey);
+
+ // Store the encryption key in memory
+ await AuthService.StoreEncryptionKeyAsync(decryptionKeyBytes);
+
+ await AuthStateProvider.GetAuthenticationStateAsync();
+
+ // Redirect to the page the user was trying to access before if set
+ var localStorageReturnUrl = await LocalStorage.GetItemAsync(ReturnUrlKey);
+ if (!string.IsNullOrEmpty(localStorageReturnUrl))
+ {
+ await LocalStorage.RemoveItemAsync(ReturnUrlKey);
+ NavigationManager.NavigateTo(localStorageReturnUrl);
+ }
+ else
+ {
+ NavigationManager.NavigateTo("/");
+ }
+ }
+#if DEBUG
+ catch (Exception ex)
+ {
+ // If in debug mode show the actual exception.
+ _serverValidationErrors.AddError(ex.ToString());
+ }
+#else
+ catch
+ {
+ // If in release mode show a generic error.
+ _serverValidationErrors.AddError(Localizer["GenericUnlockError"]);
+ }
+#endif
+ finally
+ {
+ _loadingIndicator.Hide();
+ }
+ }
}
diff --git a/apps/server/AliasVault.Client/Auth/Services/MobileLoginUtility.cs b/apps/server/AliasVault.Client/Auth/Services/MobileLoginUtility.cs
new file mode 100644
index 000000000..87134c0ce
--- /dev/null
+++ b/apps/server/AliasVault.Client/Auth/Services/MobileLoginUtility.cs
@@ -0,0 +1,220 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) aliasvault. All rights reserved.
+// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
+//
+//-----------------------------------------------------------------------
+
+namespace AliasVault.Client.Auth.Services;
+
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using AliasVault.Client.Auth.Models;
+using AliasVault.Client.Services.JsInterop;
+using AliasVault.Client.Utilities;
+using AliasVault.Shared.Models.WebApi.Auth;
+using Microsoft.Extensions.Logging;
+
+///
+/// Utility class for logging in with mobile app functionality.
+///
+public sealed class MobileLoginUtility : IDisposable
+{
+ private readonly HttpClient _httpClient;
+ private readonly JsInteropService _jsInteropService;
+ private readonly ILogger _logger;
+
+ private Timer? _pollingTimer;
+ private string? _requestId;
+ private string? _privateKey;
+ private CancellationTokenSource? _cancellationTokenSource;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The HTTP client.
+ /// The JS interop service.
+ /// The logger.
+ public MobileLoginUtility(HttpClient httpClient, JsInteropService jsInteropService, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _jsInteropService = jsInteropService;
+ _logger = logger;
+ }
+
+ ///
+ /// Initiates a mobile login request and returns the request ID for QR code generation.
+ ///
+ /// The request ID.
+ /// Thrown when the request fails.
+ public async Task InitiateAsync()
+ {
+ try
+ {
+ // Generate RSA key pair
+ var keyPair = await _jsInteropService.GenerateRsaKeyPair();
+ _privateKey = keyPair.PrivateKey;
+
+ // Send public key to server
+ var request = new MobileLoginInitiateRequest
+ {
+ ClientPublicKey = keyPair.PublicKey,
+ };
+ var response = await _httpClient.PostAsJsonAsync("v1/Auth/mobile-login/initiate", request);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new MobileLoginException(MobileLoginErrorCode.Generic);
+ }
+
+ var result = await response.Content.ReadFromJsonAsync();
+ if (result == null)
+ {
+ throw new MobileLoginException(MobileLoginErrorCode.Generic);
+ }
+
+ _requestId = result.RequestId;
+ return _requestId;
+ }
+ catch (MobileLoginException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to initiate mobile login");
+ throw new MobileLoginException(MobileLoginErrorCode.Generic);
+ }
+ }
+
+ ///
+ /// Starts polling the server for mobile login response.
+ ///
+ /// Callback for successful authentication with decrypted login result.
+ /// Callback for errors with error code.
+ /// Task.
+ public Task StartPollingAsync(Func onSuccess, Action onError)
+ {
+ if (string.IsNullOrEmpty(_requestId) || string.IsNullOrEmpty(_privateKey))
+ {
+ throw new InvalidOperationException("Must call InitiateAsync() before starting polling");
+ }
+
+ _cancellationTokenSource = new CancellationTokenSource();
+
+ // Start polling timer (every 3 seconds)
+ _pollingTimer = new Timer(async _ => await PollServerAsync(onSuccess, onError), null, TimeSpan.Zero, TimeSpan.FromSeconds(3));
+
+ // Auto-stop after 3.5 minutes (adds 1 minute buffer to default 2 minute timer for edge cases)
+ Task.Delay(TimeSpan.FromSeconds(210), _cancellationTokenSource.Token)
+ .ContinueWith(
+ _ =>
+ {
+ if (!_cancellationTokenSource.IsCancellationRequested)
+ {
+ StopPolling();
+ onError(MobileLoginErrorCode.Timeout);
+ }
+ },
+ TaskScheduler.Default);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Stops polling the server.
+ ///
+ public void StopPolling()
+ {
+ _pollingTimer?.Dispose();
+ _pollingTimer = null;
+ _cancellationTokenSource?.Cancel();
+ _cancellationTokenSource?.Dispose();
+ _cancellationTokenSource = null;
+ }
+
+ ///
+ /// Cleans up resources.
+ ///
+ public void Cleanup()
+ {
+ StopPolling();
+ _privateKey = null;
+ _requestId = null;
+ }
+
+ ///
+ public void Dispose()
+ {
+ Cleanup();
+ }
+
+ private async Task PollServerAsync(Func onSuccess, Action onError)
+ {
+ if (string.IsNullOrEmpty(_requestId) || _cancellationTokenSource?.IsCancellationRequested == true)
+ {
+ return;
+ }
+
+ try
+ {
+ var response = await _httpClient.GetAsync($"v1/Auth/mobile-login/poll/{_requestId}");
+
+ if (!response.IsSuccessStatusCode)
+ {
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ StopPolling();
+ _privateKey = null;
+ _requestId = null;
+ onError(MobileLoginErrorCode.Timeout);
+ return;
+ }
+
+ throw new InvalidOperationException($"Polling failed: {response.StatusCode}");
+ }
+
+ var result = await response.Content.ReadFromJsonAsync();
+
+ if (result?.Fulfilled == true && !string.IsNullOrEmpty(result.EncryptedSymmetricKey))
+ {
+ // Stop polling
+ StopPolling();
+
+ // Decrypt the vault decryption key directly with RSA private key
+ var decryptionKey = await _jsInteropService.DecryptWithPrivateKey(result.EncryptedDecryptionKey!, _privateKey!);
+
+ // Decrypt the symmetric key with RSA private key
+ var symmetricKeyBase64 = await _jsInteropService.DecryptWithPrivateKey(result.EncryptedSymmetricKey, _privateKey!);
+
+ // Decrypt all remaining fields using the symmetric key
+ var token = await _jsInteropService.SymmetricDecrypt(result.EncryptedToken!, symmetricKeyBase64);
+ var refreshToken = await _jsInteropService.SymmetricDecrypt(result.EncryptedRefreshToken!, symmetricKeyBase64);
+ var username = await _jsInteropService.SymmetricDecrypt(result.EncryptedUsername!, symmetricKeyBase64);
+
+ // Clear sensitive data
+ _privateKey = null;
+ _requestId = null;
+
+ // Call success callback with decrypted data
+ await onSuccess(new MobileLoginResult
+ {
+ Username = username,
+ Token = token,
+ RefreshToken = refreshToken,
+ DecryptionKey = decryptionKey,
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error during mobile login polling");
+ StopPolling();
+ _privateKey = null;
+ _requestId = null;
+ onError(MobileLoginErrorCode.Generic);
+ }
+ }
+}
diff --git a/apps/server/AliasVault.Client/Resources/Components/Auth/Login.en.resx b/apps/server/AliasVault.Client/Resources/Components/Auth/Login.en.resx
index f8c7a22b6..849057e31 100644
--- a/apps/server/AliasVault.Client/Resources/Components/Auth/Login.en.resx
+++ b/apps/server/AliasVault.Client/Resources/Components/Auth/Login.en.resx
@@ -104,10 +104,6 @@
Log in
Login button text
-
- Login to your account
- Extended login button text
-
@@ -126,7 +122,10 @@
Log in with an authenticator code instead.
Link text for logging in with authenticator
-
+
+ Log in using Mobile App
+ Link text for mobile device login
+
Your login is protected with an authenticator app. Enter your authenticator code below.
diff --git a/apps/server/AliasVault.Client/Resources/MobileLogin.en.resx b/apps/server/AliasVault.Client/Resources/MobileLogin.en.resx
new file mode 100644
index 000000000..04ef1983e
--- /dev/null
+++ b/apps/server/AliasVault.Client/Resources/MobileLogin.en.resx
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Mobile login request timed out. Please reload the page and try again.
+
+
diff --git a/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileLogin.en.resx b/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileLogin.en.resx
new file mode 100644
index 000000000..5ec771ddf
--- /dev/null
+++ b/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileLogin.en.resx
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Log in using Mobile App
+ Page title for mobile unlock feature
+
+
+ Unlock using Mobile App
+ Modal title for mobile unlock feature
+
+
+ Scan this QR code with your AliasVault mobile app to login.
+ Description instructing user to scan QR code
+
+
+ Scan this QR code with your AliasVault mobile app to unlock your vault.
+ Description instructing user to scan QR code to unlock
+
+
diff --git a/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileUnlockModal.en.resx b/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileUnlockModal.en.resx
new file mode 100644
index 000000000..16166b115
--- /dev/null
+++ b/apps/server/AliasVault.Client/Resources/Pages/Auth/MobileUnlockModal.en.resx
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Log in using Mobile App
+ Page title for mobile unlock feature
+
+
+ Unlock using Mobile App
+ Modal title for mobile unlock feature
+
+
+ Scan this QR code with your AliasVault mobile app to login and unlock your vault.
+ Description instructing user to scan QR code
+
+
diff --git a/apps/server/AliasVault.Client/Resources/Pages/Auth/Unlock.en.resx b/apps/server/AliasVault.Client/Resources/Pages/Auth/Unlock.en.resx
index ee01f41a5..da067364b 100644
--- a/apps/server/AliasVault.Client/Resources/Pages/Auth/Unlock.en.resx
+++ b/apps/server/AliasVault.Client/Resources/Pages/Auth/Unlock.en.resx
@@ -122,4 +122,8 @@
An error occurred while processing the login request. Try again (later).
Generic error message for unlock failures
+
+ Unlock with Mobile App
+ Button text for unlocking with mobile app
+
\ No newline at end of file
diff --git a/apps/server/AliasVault.Client/Resources/SharedResources.en.resx b/apps/server/AliasVault.Client/Resources/SharedResources.en.resx
index 29c48d9f1..d66cc2386 100644
--- a/apps/server/AliasVault.Client/Resources/SharedResources.en.resx
+++ b/apps/server/AliasVault.Client/Resources/SharedResources.en.resx
@@ -215,6 +215,10 @@
An error occurred. Please try again.
Generic error message
+
+ An unknown error occurred. Please try again.
+ Generic unknown error message
+
Please correct the errors below.
Validation error message
@@ -291,6 +295,10 @@
+
+ or
+ Divider text between options
+
Lock vault
Tooltip text for lock vault button
diff --git a/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs b/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs
index b9687f052..b7dcca87c 100644
--- a/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs
+++ b/apps/server/AliasVault.Client/Services/JsInterop/JsInteropService.cs
@@ -235,7 +235,7 @@ public sealed class JsInteropService(IJSRuntime jsRuntime)
try
{
// Invoke the JavaScript function and get the result as a byte array
- await jsRuntime.InvokeVoidAsync("generateQrCode", "authenticator-uri");
+ await jsRuntime.InvokeVoidAsync("generateQrCode", elementId);
}
catch (JSException ex)
{
diff --git a/apps/server/AliasVault.Client/Utilities/MobileLoginErrorCode.cs b/apps/server/AliasVault.Client/Utilities/MobileLoginErrorCode.cs
new file mode 100644
index 000000000..3bf0cb50e
--- /dev/null
+++ b/apps/server/AliasVault.Client/Utilities/MobileLoginErrorCode.cs
@@ -0,0 +1,25 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) aliasvault. All rights reserved.
+// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
+//
+//-----------------------------------------------------------------------
+
+namespace AliasVault.Client.Utilities;
+
+///
+/// Error codes for mobile login operations.
+/// These codes are used to provide translatable error messages to users.
+///
+public enum MobileLoginErrorCode
+{
+ ///
+ /// The mobile login request has timed out after 2 minutes.
+ ///
+ Timeout,
+
+ ///
+ /// A generic error occurred during mobile login.
+ ///
+ Generic,
+}
diff --git a/apps/server/AliasVault.Client/Utilities/MobileLoginException.cs b/apps/server/AliasVault.Client/Utilities/MobileLoginException.cs
new file mode 100644
index 000000000..51e75a6aa
--- /dev/null
+++ b/apps/server/AliasVault.Client/Utilities/MobileLoginException.cs
@@ -0,0 +1,32 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) aliasvault. All rights reserved.
+// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
+//
+//-----------------------------------------------------------------------
+
+namespace AliasVault.Client.Utilities;
+
+using System;
+
+///
+/// Exception thrown during mobile login operations.
+/// Contains a for translation.
+///
+public class MobileLoginException : Exception
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The error code.
+ public MobileLoginException(MobileLoginErrorCode errorCode)
+ : base($"Mobile login failed with error code: {errorCode}")
+ {
+ ErrorCode = errorCode;
+ }
+
+ ///
+ /// Gets the error code.
+ ///
+ public MobileLoginErrorCode ErrorCode { get; }
+}
diff --git a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css
index ac4ed1892..fadb8ed09 100644
--- a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css
+++ b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css
@@ -1515,6 +1515,10 @@ video {
border-width: 2px;
}
+.border-4 {
+ border-width: 4px;
+}
+
.border-b {
border-bottom-width: 1px;
}
@@ -1627,6 +1631,11 @@ video {
border-color: rgb(254 240 138 / var(--tw-border-opacity));
}
+.border-red-400 {
+ --tw-border-opacity: 1;
+ border-color: rgb(248 113 113 / var(--tw-border-opacity));
+}
+
.bg-amber-100 {
--tw-bg-opacity: 1;
background-color: rgb(254 243 199 / var(--tw-bg-opacity));
@@ -1811,6 +1820,11 @@ video {
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
+.bg-yellow-100 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(254 249 195 / var(--tw-bg-opacity));
+}
+
.bg-yellow-50 {
--tw-bg-opacity: 1;
background-color: rgb(254 252 232 / var(--tw-bg-opacity));
@@ -1821,6 +1835,11 @@ video {
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
+.bg-black {
+ --tw-bg-opacity: 1;
+ background-color: rgb(0 0 0 / var(--tw-bg-opacity));
+}
+
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1829,6 +1848,10 @@ video {
--tw-bg-opacity: 0.75;
}
+.bg-opacity-80 {
+ --tw-bg-opacity: 0.8;
+}
+
.bg-gradient-to-r {
background-image: linear-gradient(to right, var(--tw-gradient-stops));
}
@@ -1958,6 +1981,11 @@ video {
padding-bottom: 1.5rem;
}
+.py-8 {
+ padding-top: 2rem;
+ padding-bottom: 2rem;
+}
+
.pb-28 {
padding-bottom: 7rem;
}
@@ -2018,6 +2046,10 @@ video {
padding-top: 2rem;
}
+.pt-5 {
+ padding-top: 1.25rem;
+}
+
.text-left {
text-align: left;
}
@@ -2368,11 +2400,20 @@ video {
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
+.ring-inset {
+ --tw-ring-inset: inset;
+}
+
.ring-black {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(0 0 0 / var(--tw-ring-opacity));
}
+.ring-gray-300 {
+ --tw-ring-opacity: 1;
+ --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
+}
+
.ring-opacity-5 {
--tw-ring-opacity: 0.05;
}
@@ -2715,6 +2756,11 @@ video {
--tw-ring-color: rgb(243 244 246 / var(--tw-ring-opacity));
}
+.focus\:ring-gray-200:focus {
+ --tw-ring-opacity: 1;
+ --tw-ring-color: rgb(229 231 235 / var(--tw-ring-opacity));
+}
+
.focus\:ring-gray-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
@@ -2876,6 +2922,11 @@ video {
border-color: rgb(133 77 14 / var(--tw-border-opacity));
}
+.dark\:border-red-700:is(.dark *) {
+ --tw-border-opacity: 1;
+ border-color: rgb(185 28 28 / var(--tw-border-opacity));
+}
+
.dark\:bg-amber-800\/30:is(.dark *) {
background-color: rgb(146 64 14 / 0.3);
}
@@ -3018,6 +3069,10 @@ video {
background-color: rgb(113 63 18 / 0.2);
}
+.dark\:bg-red-900\/30:is(.dark *) {
+ background-color: rgb(127 29 29 / 0.3);
+}
+
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
@@ -3177,6 +3232,11 @@ video {
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
}
+.dark\:ring-gray-600:is(.dark *) {
+ --tw-ring-opacity: 1;
+ --tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity));
+}
+
.dark\:ring-offset-gray-800:is(.dark *) {
--tw-ring-offset-color: #1f2937;
}
@@ -3190,6 +3250,11 @@ video {
color: rgb(248 185 99 / var(--tw-text-opacity));
}
+.dark\:hover\:border-gray-600:hover:is(.dark *) {
+ --tw-border-opacity: 1;
+ border-color: rgb(75 85 99 / var(--tw-border-opacity));
+}
+
.dark\:hover\:bg-blue-500:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
@@ -3514,6 +3579,10 @@ video {
display: inline;
}
+ .md\:flex {
+ display: flex;
+ }
+
.md\:hidden {
display: none;
}
diff --git a/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs b/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs
index 41e7f74e3..b2475ba9a 100644
--- a/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs
+++ b/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs
@@ -137,6 +137,11 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
///
public DbSet
TaskRunnerJobs { get; set; }
+ ///
+ /// Gets or sets the MobileLoginRequests DbSet.
+ ///
+ public DbSet MobileLoginRequests { get; set; }
+
///
/// Sets up the connection string if it is not already configured.
///
diff --git a/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.Designer.cs b/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.Designer.cs
new file mode 100644
index 000000000..bc65a99fe
--- /dev/null
+++ b/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.Designer.cs
@@ -0,0 +1,981 @@
+//
+using System;
+using AliasServerDb;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace AliasServerDb.Migrations
+{
+ [DbContext(typeof(AliasServerDbContext))]
+ [Migration("20251117215634_AddMobileLoginRequest")]
+ partial class AddMobileLoginRequest
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.4")
+ .HasAnnotation("Proxies:ChangeTracking", false)
+ .HasAnnotation("Proxies:CheckEquality", false)
+ .HasAnnotation("Proxies:LazyLoading", true)
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("AliasServerDb.AdminRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("ConcurrencyStamp")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("NormalizedName")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("AdminRoles");
+ });
+
+ modelBuilder.Entity("AliasServerDb.AdminUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer");
+
+ b.Property("ConcurrencyStamp")
+ .HasColumnType("text");
+
+ b.Property("Email")
+ .HasColumnType("text");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("LastPasswordChanged")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedEmail")
+ .HasColumnType("text");
+
+ b.Property("NormalizedUserName")
+ .HasColumnType("text");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UserName")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("AdminUsers");
+ });
+
+ modelBuilder.Entity("AliasServerDb.AliasVaultRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("ConcurrencyStamp")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("NormalizedName")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("AliasVaultRoles");
+ });
+
+ modelBuilder.Entity("AliasServerDb.AliasVaultUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer");
+
+ b.Property("Blocked")
+ .HasColumnType("boolean");
+
+ b.Property("ConcurrencyStamp")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Email")
+ .HasColumnType("text");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("LastActivityDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MaxEmailAgeDays")
+ .HasColumnType("integer");
+
+ b.Property("MaxEmails")
+ .HasColumnType("integer");
+
+ b.Property("NormalizedEmail")
+ .HasColumnType("text");
+
+ b.Property("NormalizedUserName")
+ .HasColumnType("text");
+
+ b.Property("PasswordChangedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserName")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("AliasVaultUsers");
+ });
+
+ modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeviceIdentifier")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ExpireDate")
+ .HasMaxLength(255)
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IpAddress")
+ .HasMaxLength(45)
+ .HasColumnType("character varying(45)");
+
+ b.Property("PreviousTokenValue")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AliasVaultUserRefreshTokens");
+ });
+
+ modelBuilder.Entity("AliasServerDb.AuthLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AdditionalInfo")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Browser")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Client")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Country")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("DeviceType")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("EventType")
+ .HasColumnType("integer");
+
+ b.Property("FailureReason")
+ .HasColumnType("integer");
+
+ b.Property("IpAddress")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("IsSuccess")
+ .HasColumnType("boolean");
+
+ b.Property("IsSuspiciousActivity")
+ .HasColumnType("boolean");
+
+ b.Property("OperatingSystem")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("RequestPath")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("Id");
+
+ b.HasIndex(new[] { "EventType" }, "IX_EventType");
+
+ b.HasIndex(new[] { "IpAddress" }, "IX_IpAddress");
+
+ b.HasIndex(new[] { "Timestamp" }, "IX_Timestamp");
+
+ b.HasIndex(new[] { "Username", "IsSuccess", "Timestamp" }, "IX_Username_IsSuccess_Timestamp")
+ .IsDescending(false, false, true);
+
+ b.HasIndex(new[] { "Username", "Timestamp" }, "IX_Username_Timestamp")
+ .IsDescending(false, true);
+
+ b.ToTable("AuthLogs");
+ });
+
+ modelBuilder.Entity("AliasServerDb.Email", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Date")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateSystem")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EncryptedSymmetricKey")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("From")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FromDomain")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FromLocal")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MessageHtml")
+ .HasColumnType("text");
+
+ b.Property("MessagePlain")
+ .HasColumnType("text");
+
+ b.Property("MessagePreview")
+ .HasColumnType("text");
+
+ b.Property("MessageSource")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PushNotificationSent")
+ .HasColumnType("boolean");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("To")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ToDomain")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ToLocal")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserEncryptionKeyId")
+ .HasMaxLength(255)
+ .HasColumnType("uuid");
+
+ b.Property("Visible")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Date");
+
+ b.HasIndex("DateSystem");
+
+ b.HasIndex("PushNotificationSent");
+
+ b.HasIndex("ToLocal");
+
+ b.HasIndex("UserEncryptionKeyId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("Emails");
+ });
+
+ modelBuilder.Entity("AliasServerDb.EmailAttachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Bytes")
+ .IsRequired()
+ .HasColumnType("bytea");
+
+ b.Property("Date")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmailId")
+ .HasColumnType("integer");
+
+ b.Property("Filename")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Filesize")
+ .HasColumnType("integer");
+
+ b.Property("MimeType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EmailId");
+
+ b.ToTable("EmailAttachments");
+ });
+
+ modelBuilder.Entity("AliasServerDb.Log", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Application")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Exception")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Level")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("LogEvent")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("LogEvent");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MessageTemplate")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Properties")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SourceContext")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("TimeStamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Application");
+
+ b.HasIndex("TimeStamp");
+
+ b.ToTable("Logs", (string)null);
+ });
+
+ modelBuilder.Entity("AliasServerDb.MobileLoginRequest", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("ClearedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ClientIpAddress")
+ .HasColumnType("text");
+
+ b.Property("ClientPublicKey")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EncryptedDecryptionKey")
+ .HasColumnType("text");
+
+ b.Property("FulfilledAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MobileIpAddress")
+ .HasColumnType("text");
+
+ b.Property("RetrievedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex(new[] { "ClientIpAddress" }, "IX_ClientIpAddress");
+
+ b.HasIndex(new[] { "CreatedAt" }, "IX_CreatedAt");
+
+ b.HasIndex(new[] { "MobileIpAddress" }, "IX_MobileIpAddress");
+
+ b.HasIndex(new[] { "RetrievedAt", "ClearedAt", "FulfilledAt" }, "IX_RetrievedAt_ClearedAt_FulfilledAt");
+
+ b.HasIndex(new[] { "UserId" }, "IX_UserId");
+
+ b.ToTable("MobileLoginRequests");
+ });
+
+ modelBuilder.Entity("AliasServerDb.ServerSetting", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Key");
+
+ b.ToTable("ServerSettings");
+ });
+
+ modelBuilder.Entity("AliasServerDb.TaskRunnerJob", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("EndTime")
+ .HasColumnType("time without time zone");
+
+ b.Property("ErrorMessage")
+ .HasColumnType("text");
+
+ b.Property("IsOnDemand")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("RunDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("StartTime")
+ .HasColumnType("time without time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("TaskRunnerJobs");
+ });
+
+ modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Address")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("AddressDomain")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("AddressLocal")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Disabled")
+ .HasColumnType("boolean");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Address")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("UserEmailClaims");
+ });
+
+ modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsPrimary")
+ .HasColumnType("boolean");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("UserEncryptionKeys");
+ });
+
+ modelBuilder.Entity("AliasServerDb.Vault", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Client")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CredentialsCount")
+ .HasColumnType("integer");
+
+ b.Property("EmailClaimsCount")
+ .HasColumnType("integer");
+
+ b.Property("EncryptionSettings")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("EncryptionType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FileSize")
+ .HasColumnType("integer");
+
+ b.Property("RevisionNumber")
+ .HasColumnType("bigint");
+
+ b.Property("Salt")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("VaultBlob")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Verifier")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("Version")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Vaults");
+ });
+
+ modelBuilder.Entity("AliasVault.WorkerStatus.Database.WorkerServiceStatus", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CurrentStatus")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("DesiredStatus")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Heartbeat")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ServiceName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.HasKey("Id");
+
+ b.ToTable("WorkerServiceStatuses");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text");
+
+ b.Property("Xml")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("RoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("UserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.ToTable("UserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.ToTable("UserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("UserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b =>
+ {
+ b.HasOne("AliasServerDb.AliasVaultUser", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("AliasServerDb.Email", b =>
+ {
+ b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey")
+ .WithMany("Emails")
+ .HasForeignKey("UserEncryptionKeyId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("EncryptionKey");
+ });
+
+ modelBuilder.Entity("AliasServerDb.EmailAttachment", b =>
+ {
+ b.HasOne("AliasServerDb.Email", "Email")
+ .WithMany("Attachments")
+ .HasForeignKey("EmailId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Email");
+ });
+
+ modelBuilder.Entity("AliasServerDb.MobileLoginRequest", b =>
+ {
+ b.HasOne("AliasServerDb.AliasVaultUser", "User")
+ .WithMany()
+ .HasForeignKey("UserId");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
+ {
+ b.HasOne("AliasServerDb.AliasVaultUser", "User")
+ .WithMany("EmailClaims")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
+ {
+ b.HasOne("AliasServerDb.AliasVaultUser", "User")
+ .WithMany("EncryptionKeys")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("AliasServerDb.Vault", b =>
+ {
+ b.HasOne("AliasServerDb.AliasVaultUser", "User")
+ .WithMany("Vaults")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("AliasServerDb.AliasVaultUser", b =>
+ {
+ b.Navigation("EmailClaims");
+
+ b.Navigation("EncryptionKeys");
+
+ b.Navigation("Vaults");
+ });
+
+ modelBuilder.Entity("AliasServerDb.Email", b =>
+ {
+ b.Navigation("Attachments");
+ });
+
+ modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
+ {
+ b.Navigation("Emails");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.cs b/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.cs
new file mode 100644
index 000000000..ca1388dd1
--- /dev/null
+++ b/apps/server/Databases/AliasServerDb/Migrations/20251117215634_AddMobileLoginRequest.cs
@@ -0,0 +1,72 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace AliasServerDb.Migrations
+{
+ ///
+ public partial class AddMobileLoginRequest : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "MobileLoginRequests",
+ columns: table => new
+ {
+ Id = table.Column(type: "text", nullable: false),
+ ClientPublicKey = table.Column(type: "text", nullable: false),
+ EncryptedDecryptionKey = table.Column(type: "text", nullable: true),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ FulfilledAt = table.Column(type: "timestamp with time zone", nullable: true),
+ RetrievedAt = table.Column(type: "timestamp with time zone", nullable: true),
+ ClearedAt = table.Column(type: "timestamp with time zone", nullable: true),
+ ClientIpAddress = table.Column(type: "text", nullable: true),
+ MobileIpAddress = table.Column(type: "text", nullable: true),
+ UserId = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_MobileLoginRequests", x => x.Id);
+ table.ForeignKey(
+ name: "FK_MobileLoginRequests_AliasVaultUsers_UserId",
+ column: x => x.UserId,
+ principalTable: "AliasVaultUsers",
+ principalColumn: "Id");
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ClientIpAddress",
+ table: "MobileLoginRequests",
+ column: "ClientIpAddress");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_CreatedAt",
+ table: "MobileLoginRequests",
+ column: "CreatedAt");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_MobileIpAddress",
+ table: "MobileLoginRequests",
+ column: "MobileIpAddress");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_RetrievedAt_ClearedAt_FulfilledAt",
+ table: "MobileLoginRequests",
+ columns: new[] { "RetrievedAt", "ClearedAt", "FulfilledAt" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_UserId",
+ table: "MobileLoginRequests",
+ column: "UserId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "MobileLoginRequests");
+ }
+ }
+}
diff --git a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs
index 23cf10910..4d5957ab1 100644
--- a/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs
+++ b/apps/server/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs
@@ -488,6 +488,54 @@ namespace AliasServerDb.Migrations
b.ToTable("Logs", (string)null);
});
+ modelBuilder.Entity("AliasServerDb.MobileLoginRequest", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("ClearedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ClientIpAddress")
+ .HasColumnType("text");
+
+ b.Property("ClientPublicKey")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EncryptedDecryptionKey")
+ .HasColumnType("text");
+
+ b.Property("FulfilledAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MobileIpAddress")
+ .HasColumnType("text");
+
+ b.Property("RetrievedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex(new[] { "ClientIpAddress" }, "IX_ClientIpAddress");
+
+ b.HasIndex(new[] { "CreatedAt" }, "IX_CreatedAt");
+
+ b.HasIndex(new[] { "MobileIpAddress" }, "IX_MobileIpAddress");
+
+ b.HasIndex(new[] { "RetrievedAt", "ClearedAt", "FulfilledAt" }, "IX_RetrievedAt_ClearedAt_FulfilledAt");
+
+ b.HasIndex(new[] { "UserId" }, "IX_UserId");
+
+ b.ToTable("MobileLoginRequests");
+ });
+
modelBuilder.Entity("AliasServerDb.ServerSetting", b =>
{
b.Property("Key")
@@ -865,6 +913,15 @@ namespace AliasServerDb.Migrations
b.Navigation("Email");
});
+ modelBuilder.Entity("AliasServerDb.MobileLoginRequest", b =>
+ {
+ b.HasOne("AliasServerDb.AliasVaultUser", "User")
+ .WithMany()
+ .HasForeignKey("UserId");
+
+ b.Navigation("User");
+ });
+
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
diff --git a/apps/server/Databases/AliasServerDb/MobileLoginRequest.cs b/apps/server/Databases/AliasServerDb/MobileLoginRequest.cs
new file mode 100644
index 000000000..dfe34dbac
--- /dev/null
+++ b/apps/server/Databases/AliasServerDb/MobileLoginRequest.cs
@@ -0,0 +1,80 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) aliasvault. All rights reserved.
+// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
+//
+//-----------------------------------------------------------------------
+
+namespace AliasServerDb;
+
+using Microsoft.EntityFrameworkCore;
+
+///
+/// Mobile login request entity for storing temporary login requests.
+///
+[Index(nameof(RetrievedAt), nameof(ClearedAt), nameof(FulfilledAt), Name = "IX_RetrievedAt_ClearedAt_FulfilledAt")]
+[Index(nameof(ClientIpAddress), Name = "IX_ClientIpAddress")]
+[Index(nameof(MobileIpAddress), Name = "IX_MobileIpAddress")]
+[Index(nameof(CreatedAt), Name = "IX_CreatedAt")]
+[Index(nameof(UserId), Name = "IX_UserId")]
+public class MobileLoginRequest
+{
+ ///
+ /// Gets or sets the unique identifier for this login request.
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the public key from the client (base64 encoded).
+ ///
+ public string ClientPublicKey { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the encrypted decryption key from the mobile app (base64 encoded).
+ /// Will be null until mobile app responds.
+ ///
+ public string? EncryptedDecryptionKey { get; set; }
+
+ ///
+ /// Gets or sets the created timestamp.
+ ///
+ public DateTime CreatedAt { get; set; }
+
+ ///
+ /// Gets or sets the fulfilled timestamp (when mobile app submitted the response).
+ ///
+ public DateTime? FulfilledAt { get; set; }
+
+ ///
+ /// Gets or sets the retrieved timestamp (when client successfully retrieved and decrypted).
+ ///
+ public DateTime? RetrievedAt { get; set; }
+
+ ///
+ /// Gets or sets the timestamp when sensitive data was cleared from this record.
+ /// Sensitive data (ClientPublicKey, EncryptedDecryptionKey, Salt, etc.) is cleared
+ /// after a timeout period to minimize risk if server is compromised.
+ ///
+ public DateTime? ClearedAt { get; set; }
+
+ ///
+ /// Gets or sets the IP address of the client that initiated the request.
+ ///
+ public string? ClientIpAddress { get; set; }
+
+ ///
+ /// Gets or sets the IP address of the mobile device that fulfilled the request.
+ ///
+ public string? MobileIpAddress { get; set; }
+
+ ///
+ /// Gets or sets the user ID (foreign key to AliasVaultUser).
+ /// Null when record is created, populated when mobile app fulfills the request.
+ ///
+ public string? UserId { get; set; }
+
+ ///
+ /// Gets or sets the navigation property to the user.
+ ///
+ public virtual AliasVaultUser? User { get; set; }
+}
diff --git a/apps/server/Services/AliasVault.TaskRunner/Tasks/LogCleanupTask.cs b/apps/server/Services/AliasVault.TaskRunner/Tasks/LogCleanupTask.cs
index 48f17a9b0..8f58140bc 100644
--- a/apps/server/Services/AliasVault.TaskRunner/Tasks/LogCleanupTask.cs
+++ b/apps/server/Services/AliasVault.TaskRunner/Tasks/LogCleanupTask.cs
@@ -69,5 +69,41 @@ public class LogCleanupTask : IMaintenanceTask
.ExecuteDeleteAsync(cancellationToken);
_logger.LogInformation("Deleted {Count} auth log entries older than {Days} days", deletedCount, settings.AuthLogRetentionDays);
}
+
+ if (settings.MobileLoginLogRetentionDays > 0)
+ {
+ var cutoffDate = DateTime.UtcNow.AddDays(-settings.MobileLoginLogRetentionDays);
+ var deletedCount = await dbContext.MobileLoginRequests
+ .Where(x => x.CreatedAt < cutoffDate)
+ .ExecuteDeleteAsync(cancellationToken);
+ _logger.LogInformation("Deleted {Count} mobile login request entries older than {Days} days", deletedCount, settings.MobileLoginLogRetentionDays);
+ }
+
+ // Clear sensitive data from stale mobile login requests (fulfilled > 10 minutes ago but not retrieved)
+ const int sensitiveDataTimeoutMinutes = 10;
+ var cutoffTime = DateTime.UtcNow.AddMinutes(-sensitiveDataTimeoutMinutes);
+
+ var staleRequests = await dbContext.MobileLoginRequests
+ .Where(r =>
+ r.FulfilledAt != null &&
+ r.FulfilledAt < cutoffTime &&
+ r.RetrievedAt == null &&
+ r.ClearedAt == null)
+ .ToListAsync(cancellationToken);
+
+ if (staleRequests.Count > 0)
+ {
+ var now = DateTime.UtcNow;
+ foreach (var request in staleRequests)
+ {
+ // Clear all sensitive data
+ request.ClientPublicKey = string.Empty;
+ request.EncryptedDecryptionKey = null;
+ request.ClearedAt = now;
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+ _logger.LogInformation("Cleared encrypted data from {Count} fulfilled but not retrieved mobile login requests", staleRequests.Count);
+ }
}
}
diff --git a/apps/server/Shared/AliasVault.RazorComponents/StatusPill.razor b/apps/server/Shared/AliasVault.RazorComponents/StatusPill.razor
index 31a82f827..e7f953f48 100644
--- a/apps/server/Shared/AliasVault.RazorComponents/StatusPill.razor
+++ b/apps/server/Shared/AliasVault.RazorComponents/StatusPill.razor
@@ -23,9 +23,32 @@
[Parameter]
public string TextFalse { get; set; } = "Disabled";
- private string PillClass => Enabled
- ? "bg-green-100 text-green-800"
- : "bg-red-100 text-red-800";
+ ///
+ /// Optional color override: "green", "yellow", "red". If not specified, uses Enabled parameter.
+ ///
+ [Parameter]
+ public string? Color { get; set; }
+
+ private string PillClass
+ {
+ get
+ {
+ if (!string.IsNullOrEmpty(Color))
+ {
+ return Color.ToLower() switch
+ {
+ "green" => "bg-green-100 text-green-800",
+ "yellow" => "bg-yellow-100 text-yellow-800",
+ "red" => "bg-red-100 text-red-800",
+ _ => "bg-gray-100 text-gray-800"
+ };
+ }
+
+ return Enabled
+ ? "bg-green-100 text-green-800"
+ : "bg-red-100 text-red-800";
+ }
+ }
private string StatusText => Enabled ? TextTrue : TextFalse;
}
diff --git a/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs b/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs
index 3eab0434e..6cbe217f2 100644
--- a/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs
+++ b/apps/server/Shared/AliasVault.Shared.Server/Models/ServerSettingsModel.cs
@@ -22,6 +22,12 @@ public class ServerSettingsModel
///
public int AuthLogRetentionDays { get; set; } = 30;
+ ///
+ /// Gets or sets the mobile login log retention days. Defaults to 30.
+ /// Mobile login requests older than this will be permanently deleted.
+ ///
+ public int MobileLoginLogRetentionDays { get; set; } = 30;
+
///
/// Gets or sets the email retention days. Defaults to 0 (unlimited).
///
diff --git a/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs b/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs
index 2b92b1e40..137b0c5a4 100644
--- a/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs
+++ b/apps/server/Shared/AliasVault.Shared/Models/Enums/ApiErrorCode.cs
@@ -133,4 +133,14 @@ public enum ApiErrorCode
/// Vault is not up-to-date and requires synchronization.
///
VAULT_NOT_UP_TO_DATE,
+
+ ///
+ /// Mobile login request not found or expired.
+ ///
+ MOBILE_LOGIN_REQUEST_NOT_FOUND,
+
+ ///
+ /// Mobile login request already fulfilled.
+ ///
+ MOBILE_LOGIN_REQUEST_ALREADY_FULFILLED,
}
diff --git a/apps/server/Shared/AliasVault.Shared/Models/Enums/AuthEventType.cs b/apps/server/Shared/AliasVault.Shared/Models/Enums/AuthEventType.cs
index ca8b9829f..c84886146 100644
--- a/apps/server/Shared/AliasVault.Shared/Models/Enums/AuthEventType.cs
+++ b/apps/server/Shared/AliasVault.Shared/Models/Enums/AuthEventType.cs
@@ -27,6 +27,11 @@ public enum AuthEventType
///
Logout = 3,
+ ///
+ /// Represents a mobile login attempt (login via QR code from mobile app).
+ ///
+ MobileLogin = 4,
+
///
/// Represents JWT access token refresh event issued by client to API.
///
diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateRequest.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateRequest.cs
new file mode 100644
index 000000000..f08cedb6f
--- /dev/null
+++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateRequest.cs
@@ -0,0 +1,19 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) aliasvault. All rights reserved.
+// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
+//
+//-----------------------------------------------------------------------
+
+namespace AliasVault.Shared.Models.WebApi.Auth;
+
+///
+/// Request model for initiating a mobile login request.
+///
+public class MobileLoginInitiateRequest
+{
+ ///
+ /// Gets or sets the public key from the client (base64 encoded).
+ ///
+ public required string ClientPublicKey { get; set; }
+}
diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateResponse.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateResponse.cs
new file mode 100644
index 000000000..59a02e534
--- /dev/null
+++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginInitiateResponse.cs
@@ -0,0 +1,19 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) aliasvault. All rights reserved.
+// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
+//
+//-----------------------------------------------------------------------
+
+namespace AliasVault.Shared.Models.WebApi.Auth;
+
+///
+/// Response model for mobile login initiate request.
+///
+public class MobileLoginInitiateResponse
+{
+ ///
+ /// Gets or sets the unique identifier for this login request.
+ ///
+ public required string RequestId { get; set; }
+}
diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginPollResponse.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginPollResponse.cs
new file mode 100644
index 000000000..ec09c1e52
--- /dev/null
+++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginPollResponse.cs
@@ -0,0 +1,48 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) aliasvault. All rights reserved.
+// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
+//
+//-----------------------------------------------------------------------
+
+namespace AliasVault.Shared.Models.WebApi.Auth;
+
+///
+/// Response model for polling mobile login status.
+/// All sensitive data is encrypted with AES-256, and the AES key is encrypted with client's RSA public key.
+/// Client decrypts username to call /login endpoint for salt and encryption settings.
+///
+public class MobileLoginPollResponse
+{
+ ///
+ /// Gets or sets a value indicating whether the request has been fulfilled by the mobile app.
+ ///
+ public required bool Fulfilled { get; set; }
+
+ ///
+ /// Gets or sets the AES symmetric key encrypted with client's RSA public key (base64 encoded).
+ /// Used to decrypt all encrypted fields. Null if not fulfilled.
+ ///
+ public string? EncryptedSymmetricKey { get; set; }
+
+ ///
+ /// Gets or sets the JWT token encrypted with AES symmetric key (base64 encoded). Null if not fulfilled.
+ ///
+ public string? EncryptedToken { get; set; }
+
+ ///
+ /// Gets or sets the refresh token encrypted with AES symmetric key (base64 encoded). Null if not fulfilled.
+ ///
+ public string? EncryptedRefreshToken { get; set; }
+
+ ///
+ /// Gets or sets the vault decryption key encrypted with client's RSA public key (base64 encoded). Null if not fulfilled.
+ ///
+ public string? EncryptedDecryptionKey { get; set; }
+
+ ///
+ /// Gets or sets the username encrypted with AES symmetric key (base64 encoded).
+ /// Retrieved from User via UserId FK. Null if not fulfilled.
+ ///
+ public string? EncryptedUsername { get; set; }
+}
diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginSubmitRequest.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginSubmitRequest.cs
new file mode 100644
index 000000000..da5c87b8b
--- /dev/null
+++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/Auth/MobileLoginSubmitRequest.cs
@@ -0,0 +1,24 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) aliasvault. All rights reserved.
+// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
+//
+//-----------------------------------------------------------------------
+
+namespace AliasVault.Shared.Models.WebApi.Auth;
+
+///
+/// Request model for submitting mobile login response from mobile app.
+///
+public class MobileLoginSubmitRequest
+{
+ ///
+ /// Gets or sets the unique identifier for this login request.
+ ///
+ public required string RequestId { get; set; }
+
+ ///
+ /// Gets or sets the encrypted decryption key (base64 encoded).
+ ///
+ public required string EncryptedDecryptionKey { get; set; }
+}
diff --git a/apps/server/Tests/AliasVault.E2ETests/Common/BrowserExtensionPlaywrightTest.cs b/apps/server/Tests/AliasVault.E2ETests/Common/BrowserExtensionPlaywrightTest.cs
index 404a0242e..2a1cad266 100644
--- a/apps/server/Tests/AliasVault.E2ETests/Common/BrowserExtensionPlaywrightTest.cs
+++ b/apps/server/Tests/AliasVault.E2ETests/Common/BrowserExtensionPlaywrightTest.cs
@@ -103,7 +103,7 @@ public class BrowserExtensionPlaywrightTest : ClientPlaywrightTest
// Test vault loading with username and password
await extensionPopup.FillAsync("input[type='text']", TestUserUsername);
await extensionPopup.FillAsync("input[type='password']", TestUserPassword);
- await extensionPopup.ClickAsync("button:has-text('Login')");
+ await extensionPopup.ClickAsync("button:has-text('Log in')");
// Wait for login to complete by waiting for expected text.
if (waitForLogin)
diff --git a/apps/server/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs b/apps/server/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs
index 16c2bf7a8..673b40f8c 100644
--- a/apps/server/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs
+++ b/apps/server/Tests/AliasVault.E2ETests/Common/ClientPlaywrightTest.cs
@@ -173,7 +173,7 @@ public class ClientPlaywrightTest : PlaywrightTest
{ "password", TestUserPassword },
});
- var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Unlock" });
+ var submitButton = Page.Locator("#unlock-button");
await submitButton.ClickAsync();
// Wait for the sync page to show
diff --git a/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/PasswordLockoutTests.cs b/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/PasswordLockoutTests.cs
index 7ea6ff501..39fe5c27c 100644
--- a/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/PasswordLockoutTests.cs
+++ b/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/PasswordLockoutTests.cs
@@ -32,7 +32,7 @@ public class PasswordLockoutTests : AdminPlaywrightTest
{
await Page.Locator("input[id='username']").FillAsync(TestUserUsername);
await Page.Locator("input[id='password']").FillAsync("wrongpassword");
- var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Login to your account" });
+ var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Login" });
await submitButton.ClickAsync();
// Wait for the text "Error: Invalid login attempt." to appear if we expect not to be locked out yet..
diff --git a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard1/JwtTokenTests.cs b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard1/JwtTokenTests.cs
index 4b57dfa67..cf2bb68ae 100644
--- a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard1/JwtTokenTests.cs
+++ b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard1/JwtTokenTests.cs
@@ -66,7 +66,7 @@ public class JwtTokenTests : ClientPlaywrightTest
{ "password", TestUserPassword },
});
- var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Unlock" });
+ var submitButton = Page.Locator("#unlock-button");
await submitButton.ClickAsync();
// Check if we get redirected back to the page we were trying to access.
@@ -96,9 +96,9 @@ public class JwtTokenTests : ClientPlaywrightTest
// Not all pages do a webapi call on load everytime so we need to navigate to a page that does.
await NavigateUsingBlazorRouter("test/2");
- await WaitForUrlAsync("user/login", "Login");
+ await WaitForUrlAsync("user/login", "Log in");
var pageContent = await Page.TextContentAsync("body");
- Assert.That(pageContent, Does.Contain("Login"), "No redirect to login while refresh token should be expired.");
+ Assert.That(pageContent, Does.Contain("Log in"), "No redirect to login while refresh token should be expired.");
}
}
diff --git a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/UnlockTests.cs b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/UnlockTests.cs
index 655491b15..44ee7bea0 100644
--- a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/UnlockTests.cs
+++ b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard5/UnlockTests.cs
@@ -65,7 +65,7 @@ public class UnlockTests : ClientPlaywrightTest
{ "password", TestUserPassword },
});
- var submitButton = Page.GetByRole(AriaRole.Button, new() { Name = "Unlock" });
+ var submitButton = Page.Locator("#unlock-button");
await submitButton.ClickAsync();
// Check if we get redirected back to the page we were trying to access.
diff --git a/apps/server/Tests/AliasVault.IntegrationTests/TaskRunner/TaskRunnerTests.cs b/apps/server/Tests/AliasVault.IntegrationTests/TaskRunner/TaskRunnerTests.cs
index 46ac8c83d..dbb3980f3 100644
--- a/apps/server/Tests/AliasVault.IntegrationTests/TaskRunner/TaskRunnerTests.cs
+++ b/apps/server/Tests/AliasVault.IntegrationTests/TaskRunner/TaskRunnerTests.cs
@@ -473,6 +473,168 @@ public class TaskRunnerTests
Assert.That(storedSetting.Value, Is.EqualTo("0"), "MarkUserInactiveAfterDays should be 0 (disabled)");
}
+ ///
+ /// Tests the Mobile Login Log Cleanup task.
+ ///
+ /// Task.
+ [Test]
+ public async Task MobileLoginLogCleanup()
+ {
+ // Arrange
+ await using var dbContext = await _testHostBuilder.GetDbContextAsync();
+
+ // Set mobile login retention to 30 days
+ var setting = new ServerSetting
+ {
+ Key = "MobileLoginLogRetentionDays",
+ Value = "30",
+ };
+ dbContext.ServerSettings.Add(setting);
+ await dbContext.SaveChangesAsync();
+
+ // Create old mobile login requests (should be deleted)
+ for (int i = 0; i < 20; i++)
+ {
+ var oldRequest = new MobileLoginRequest
+ {
+ Id = Guid.NewGuid().ToString(),
+ ClientPublicKey = "old-test-key",
+ CreatedAt = DateTime.UtcNow.AddDays(-40 - i), // 40+ days old
+ ClientIpAddress = "192.168.1.1",
+ };
+ dbContext.MobileLoginRequests.Add(oldRequest);
+ }
+
+ // Create recent mobile login requests (should be kept)
+ for (int i = 0; i < 30; i++)
+ {
+ var recentRequest = new MobileLoginRequest
+ {
+ Id = Guid.NewGuid().ToString(),
+ ClientPublicKey = "recent-test-key",
+ CreatedAt = DateTime.UtcNow.AddDays(-i), // 0-29 days old
+ ClientIpAddress = "192.168.1.2",
+ };
+ dbContext.MobileLoginRequests.Add(recentRequest);
+ }
+
+ await dbContext.SaveChangesAsync();
+
+ // Act
+ await _testHost.StartAsync();
+ await WaitForMaintenanceJobCompletion();
+
+ // Assert
+ var remainingRequests = await dbContext.MobileLoginRequests.ToListAsync();
+ Assert.That(remainingRequests, Has.Count.EqualTo(30), "Only recent mobile login requests (last 30 days) should remain");
+ }
+
+ ///
+ /// Tests the Mobile Login Sensitive Data Cleanup task (runs during nightly maintenance).
+ /// Sensitive data is automatically cleared after 10 minutes (hardcoded).
+ ///
+ /// Task.
+ [Test]
+ public async Task MobileLoginSensitiveDataCleanup()
+ {
+ // Arrange
+ await using var dbContext = await _testHostBuilder.GetDbContextAsync();
+
+ // Create test user
+ var user = new AliasVaultUser
+ {
+ UserName = "testuser",
+ Email = "testuser@example.tld",
+ };
+ dbContext.AliasVaultUsers.Add(user);
+ await dbContext.SaveChangesAsync();
+
+ // Create fulfilled-but-not-retrieved request that's old enough to be cleared (> 10 minutes)
+ var staleRequest = new MobileLoginRequest
+ {
+ Id = Guid.NewGuid().ToString(),
+ ClientPublicKey = "stale-public-key",
+ EncryptedDecryptionKey = "encrypted-key-data",
+ UserId = user.Id,
+ CreatedAt = DateTime.UtcNow.AddMinutes(-15),
+ FulfilledAt = DateTime.UtcNow.AddMinutes(-12), // Fulfilled 12 minutes ago (exceeds 10 min timeout)
+ RetrievedAt = null, // Not yet retrieved
+ ClearedAt = null, // Not yet cleared
+ ClientIpAddress = "192.168.1.1",
+ MobileIpAddress = "10.0.0.1",
+ };
+ dbContext.MobileLoginRequests.Add(staleRequest);
+
+ // Create fulfilled-but-not-retrieved request that's too recent to clear (< 10 minutes)
+ var recentRequest = new MobileLoginRequest
+ {
+ Id = Guid.NewGuid().ToString(),
+ ClientPublicKey = "recent-public-key",
+ EncryptedDecryptionKey = "encrypted-key-data",
+ UserId = user.Id,
+ CreatedAt = DateTime.UtcNow.AddMinutes(-6),
+ FulfilledAt = DateTime.UtcNow.AddMinutes(-5), // Fulfilled 5 minutes ago (under 10 min timeout)
+ RetrievedAt = null,
+ ClearedAt = null,
+ ClientIpAddress = "192.168.1.2",
+ MobileIpAddress = "10.0.0.2",
+ };
+ dbContext.MobileLoginRequests.Add(recentRequest);
+
+ // Create completed request (already retrieved)
+ var completedRequest = new MobileLoginRequest
+ {
+ Id = Guid.NewGuid().ToString(),
+ ClientPublicKey = "completed-public-key",
+ EncryptedDecryptionKey = "encrypted-key-data",
+ UserId = user.Id,
+ CreatedAt = DateTime.UtcNow.AddMinutes(-15),
+ FulfilledAt = DateTime.UtcNow.AddMinutes(-12),
+ RetrievedAt = DateTime.UtcNow.AddMinutes(-11), // Already retrieved
+ ClearedAt = DateTime.UtcNow.AddMinutes(-11), // Already cleared when retrieved
+ ClientIpAddress = "192.168.1.3",
+ MobileIpAddress = "10.0.0.3",
+ };
+ dbContext.MobileLoginRequests.Add(completedRequest);
+
+ await dbContext.SaveChangesAsync();
+
+ // Act - Run the nightly maintenance cleanup
+ await _testHost.StartAsync();
+ await WaitForMaintenanceJobCompletion();
+
+ // Assert - Reload entities from database to get updated values
+ await dbContext.Entry(staleRequest).ReloadAsync();
+ await dbContext.Entry(recentRequest).ReloadAsync();
+ await dbContext.Entry(completedRequest).ReloadAsync();
+
+ var staleAfterCleanup = staleRequest;
+ Assert.Multiple(() =>
+ {
+ // Stale request should have sensitive data cleared
+ Assert.That(staleAfterCleanup.ClientPublicKey, Is.Empty, "Stale request ClientPublicKey should be cleared");
+ Assert.That(staleAfterCleanup.EncryptedDecryptionKey, Is.Null, "Stale request EncryptedDecryptionKey should be cleared");
+ Assert.That(staleAfterCleanup.ClearedAt, Is.Not.Null, "Stale request ClearedAt should be set");
+
+ // Metadata should be preserved for abuse tracking
+ Assert.That(staleAfterCleanup.ClientIpAddress, Is.EqualTo("192.168.1.1"), "Client IP should be preserved");
+ Assert.That(staleAfterCleanup.MobileIpAddress, Is.EqualTo("10.0.0.1"), "Mobile IP should be preserved");
+ Assert.That(staleAfterCleanup.UserId, Is.EqualTo(user.Id), "UserId should be preserved");
+ });
+
+ var recentAfterCleanup = recentRequest;
+ Assert.Multiple(() =>
+ {
+ // Recent request should still have sensitive data (not old enough)
+ Assert.That(recentAfterCleanup.ClientPublicKey, Is.EqualTo("recent-public-key"), "Recent request should retain sensitive data");
+ Assert.That(recentAfterCleanup.EncryptedDecryptionKey, Is.Not.Null, "Recent request should retain encrypted key");
+ Assert.That(recentAfterCleanup.ClearedAt, Is.Null, "Recent request should not be cleared yet");
+ });
+
+ var completedAfterCleanup = completedRequest;
+ Assert.That(completedAfterCleanup.ClearedAt, Is.Not.Null, "Completed request should remain cleared");
+ }
+
///
/// Creates a base email with static required fields.
///
diff --git a/docs/architecture/index.md b/docs/architecture/index.md
index a5d802e31..6182c501e 100644
--- a/docs/architecture/index.md
+++ b/docs/architecture/index.md
@@ -6,6 +6,18 @@ nav_order: 5
---
# Architecture
+{: .no_toc }
+
+
+
+ Table of contents
+
+ {: .text-delta }
+- TOC
+{:toc}
+
+
+---
AliasVault implements zero-knowledge encryption where sensitive user data never leaves the client device in unencrypted form. Below is a detailed explanation of how the system secures user data and communications.
@@ -13,8 +25,6 @@ AliasVault implements zero-knowledge encryption where sensitive user data never
- **Vault Data** (usernames, passwords, notes, passkeys etc.) is fully encrypted client-side before being sent to the server. The server cannot decrypt any vault contents.
- **Email Contents**: When emails are received by the server, their contents are immediately encrypted with your public key before being saved. Only you can decrypt and read them with your private key.
-*Note: email aliases are stored on the server as "claims" which are linked to internal user IDs for routing purposes.*
-
## Diagram
The security architecture diagram below illustrates all encryption and authentication processes used in AliasVault to secure user data and communications.
@@ -76,7 +86,7 @@ You can also view the diagram in a browser-friendly HTML format: [AliasVault Sec
> Note: The use of a symmetric key for email content encryption and asymmetric encryption for the symmetric key (hybrid encryption) is implemented due to RSA's limitations on encryption string length and for better performance.
-### 5. Passkey Authentication System
+### 5. Passkey Authentication
AliasVault includes a virtual passkey authenticator that implements the WebAuthn Level 2 specification, allowing users to securely store and use passkeys for passwordless authentication across websites and services.
@@ -134,18 +144,55 @@ AliasVault includes a virtual passkey authenticator that implements the WebAuthn
- PRF is supported via browser extension and iOS (0.24.0+)
- Android support is pending due to limited Android API support
-## Security Benefits
-- Zero-knowledge encryption: entire vault is encrypted client-side before transmission
-- Email contents are encrypted with your public key immediately upon receipt by the server
-- Master password never leaves the client device
-- All sensitive operations (key derivation, encryption/decryption) happen locally
-- Server stores only encrypted vault data and encrypted email contents
-- Multi-layer hybrid encryption for emails provides secure communication
-- Optional 2FA adds an additional security layer
-- Use of established cryptographic standards (Argon2id, AES-256-GCM, RSA/OAEP)
-- Passkey private keys remain encrypted in vault at all times
-- Cross-platform passkey sync without compromising security
-- WebAuthn compliance eliminates phishing risks through domain binding
-- No personally identifiable information required for registration
+### 6. Login with Mobile
-This security architecture ensures that even if the server is compromised, vault contents and email messages remain secure and unreadable as all sensitive operations and keys remain strictly on the client side. Email aliases (stored on the server as "claims" for routing) are linked to internal user IDs, not real-world identities.
+AliasVault includes a secure "Login with Mobile" feature that allows users to unlock their vault on web browsers or browser extensions by scanning a QR code with their mobile app. This system provides a convenient authentication method while maintaining zero-knowledge security through end-to-end encryption.
+
+#### Security Architecture
+
+The mobile login system uses a hybrid encryption approach combining RSA asymmetric encryption and AES-256-GCM symmetric encryption to ensure that:
+- The server never has access to the user's decryption key in plaintext
+- Only the authorized client that initiated the request can decrypt the transmitted data
+- No sensitive data persists on the server after retrieval
+
+#### Authentication Flow
+
+1. **Initiation (Browser/Extension Client)**
+ - Client generates an RSA-2048 key pair locally
+ - Public key is sent to the server
+ - Server creates a unique request ID and stores the public key
+ - Client generates a QR code containing the request ID
+ - Private key is kept only in memory (never persisted to disk)
+
+2. **QR Code Scanning (Mobile App)**
+ - User scans the QR code with their authenticated mobile app
+ - Mobile app retrieves the public key from server
+ - Mobile app encrypts the user's vault decryption key using the RSA public key
+ - Encrypted decryption key is sent to server
+ - Server stores the encrypted decryption key and marks the request as fulfilled
+
+3. **Polling and Retrieval (Browser/Extension Client)**
+ - Client polls the server every few seconds
+ - Polling continues for up to 2 minutes (3-minute server-side timeout for buffer)
+ - When fulfilled, server:
+ - Generates a fresh JWT access token and refresh token for the user
+ - Creates a random 256-bit AES symmetric key
+ - Encrypts the JWT tokens and username using this symmetric key
+ - Encrypts the symmetric key itself using the client's RSA public key
+ - Returns all encrypted data in the response
+ - Immediately marks the request as retrieved and clears sensitive data from database
+
+4. **Decryption (Browser/Extension Client)**
+ - Client uses its RSA private key to decrypt the symmetric key
+ - Client uses the symmetric key to decrypt the JWT tokens and username
+ - Client uses the RSA private key to decrypt the vault decryption key
+ - Client can now unlock the vault using the decryption key and stores it in memory
+ - RSA private key is immediately purged from memory
+
+#### Security Properties
+
+- **Zero-Knowledge**: The server never has access to the vault decryption key in plaintext. It only temporarily stores the RSA-encrypted version.
+- **One-Time Use**: Once a mobile login request is retrieved by the client, it cannot be accessed again. The encrypted data is immediately cleared from the database.
+- **Automatic Expiration**: Fulfilled but unretrieved requests are automatically deleted by the server within 24 hours to prevent stale data accumulation.
+- **Man-in-the-Middle Protection**: The encryption scheme ensures that any eavesdroppers cannot intercept the decryption key. Only the local client that started the mobile login request has the private key for decryption.
+- **Short-Lived Requests**: The 3-minute timeout window limits the attack surface for QR code interception.
diff --git a/docs/mobile-apps/android/build-from-source.md b/docs/mobile-apps/android/build-from-source.md
index 7fb014c62..3bd7b36f2 100644
--- a/docs/mobile-apps/android/build-from-source.md
+++ b/docs/mobile-apps/android/build-from-source.md
@@ -32,27 +32,14 @@ cd aliasvault/apps/mobile-app
npm install
```
-4. Install and build Android dependencies:
+4. Deploy release build to your device via React Native automatically:
+
```bash
-cd android
-./gradlew assembleRelease
+npx react-native run-android --mode release
```
-5. Deploy release build to your device:
-For MacOS, install adb to deploy the app to a phone or simulator via command line:
-```bash
-# Install adb
-brew install android-platform-tools
-
-# List devices (physical devices connected via USB and any running simulators)
-adb devices
-
-# Deploy to chosen device
-adb -s [device-id] install android/app/build/outputs/apk/release/app-release.apk
-```
-
-6. For publishing to Google Play:
+5. For publishing to Google Play:
Create a local gradle file in your user directory where the keystore credentials will be placed
```bash
nano ~/.gradle/gradle.properties
diff --git a/shared/models/src/webapi/AuthEventType.ts b/shared/models/src/webapi/AuthEventType.ts
index bd200c404..9991ea0c4 100644
--- a/shared/models/src/webapi/AuthEventType.ts
+++ b/shared/models/src/webapi/AuthEventType.ts
@@ -17,6 +17,11 @@ export enum AuthEventType {
*/
Logout = 3,
+ /**
+ * Represents a mobile login attempt (login via QR code from mobile app).
+ */
+ MobileLogin = 4,
+
/**
* Represents JWT access token refresh event issued by client to API.
*/
diff --git a/shared/models/src/webapi/MobileLogin.ts b/shared/models/src/webapi/MobileLogin.ts
new file mode 100644
index 000000000..a0c0c0b11
--- /dev/null
+++ b/shared/models/src/webapi/MobileLogin.ts
@@ -0,0 +1,33 @@
+/**
+ * Mobile login initiate request type.
+ */
+export type MobileLoginInitiateRequest = {
+ clientPublicKey: string;
+}
+
+/**
+ * Mobile login initiate response type.
+ */
+export type MobileLoginInitiateResponse = {
+ requestId: string;
+}
+
+/**
+ * Mobile login submit request type.
+ */
+export type MobileLoginSubmitRequest = {
+ requestId: string;
+ encryptedDecryptionKey: string;
+}
+
+/**
+ * Mobile login poll response type.
+ */
+export type MobileLoginPollResponse = {
+ fulfilled: boolean;
+ encryptedSymmetricKey: string | null;
+ encryptedToken: string | null;
+ encryptedRefreshToken: string | null;
+ encryptedDecryptionKey: string | null;
+ encryptedUsername: string | null;
+}
diff --git a/shared/models/src/webapi/ValidateLogin.ts b/shared/models/src/webapi/ValidateLogin.ts
index e64eaf216..81360c9a2 100644
--- a/shared/models/src/webapi/ValidateLogin.ts
+++ b/shared/models/src/webapi/ValidateLogin.ts
@@ -19,14 +19,19 @@ export type ValidateLoginRequest2Fa = {
clientSessionProof: string;
}
+/**
+ * Token model type.
+ */
+export type TokenModel = {
+ token: string;
+ refreshToken: string;
+}
+
/**
* Validate login response type.
*/
export type ValidateLoginResponse = {
requiresTwoFactor: boolean;
- token?: {
- token: string;
- refreshToken: string;
- };
+ token?: TokenModel;
serverSessionProof: string;
}
\ No newline at end of file
diff --git a/shared/models/src/webapi/index.ts b/shared/models/src/webapi/index.ts
index 75c1c8832..43ea8d481 100644
--- a/shared/models/src/webapi/index.ts
+++ b/shared/models/src/webapi/index.ts
@@ -21,3 +21,4 @@ export * from './PasswordChangeInitiateResponse';
export * from './VaultPasswordChangeRequest';
export * from './BadRequestResponse';
export * from './AuthEventType';
+export * from './MobileLogin';