Compare commits

...

162 Commits
0.1.0 ... 0.3.0

Author SHA1 Message Date
Leendert de Borst
81a52dcc0e Merge pull request #103 from lanedirt/85-update-readme-instructions-with-e2e-encryption-explanation
Update readme instructions
2024-07-12 07:16:00 -07:00
Leendert de Borst
dd3818499c Update README.md 2024-07-12 16:08:06 +02:00
Leendert de Borst
b141196384 Update README.md 2024-07-12 15:35:19 +02:00
Leendert de Borst
2dfe8c64e5 Update README.md (#85) 2024-07-12 15:31:18 +02:00
Leendert de Borst
033a513c92 Merge pull request #99 from lanedirt/98-improve-oobe-user-experience
Rename email prefix to email field and refactor logic (#98)
2024-07-12 04:33:03 -07:00
Leendert de Borst
0bbb504511 Update DbPersistTest.cs (#98) 2024-07-12 13:11:45 +02:00
Leendert de Borst
88fe86c19a Update DbPersistTest.cs (#98) 2024-07-12 12:56:31 +02:00
Leendert de Borst
3ccf239d84 Update DbPersistTest.cs (#98) 2024-07-12 12:50:20 +02:00
Leendert de Borst
c6ef654c87 Layout update (#98) 2024-07-12 12:45:42 +02:00
Leendert de Borst
4ef0373d31 Add global loading indicator to save actions, refactoring (#98) 2024-07-12 11:45:30 +02:00
Leendert de Borst
71f91b8050 Update csproj to conditionally include appsettings.Development.json (#98) 2024-07-12 10:11:02 +02:00
Leendert de Borst
6d8315ac4e Remove appsettings.Development from git (#98) 2024-07-12 09:52:45 +02:00
Leendert de Borst
08e550f46f Add appsettings.json for E2E test local config override (#98) 2024-07-12 09:48:19 +02:00
Leendert de Borst
dfa67a00e3 Rename email prefix to email field and refactor logic (#98) 2024-07-11 22:55:55 +02:00
Leendert de Borst
cdd58773de Merge pull request #97 from lanedirt/95-add-favicon-extract-timeout
Add favicon extract timeout
2024-07-09 13:08:37 -07:00
Leendert de Borst
808ecb865b Merge branch 'main' into 95-add-favicon-extract-timeout 2024-07-09 13:08:24 -07:00
Leendert de Borst
277ff6c012 Move timeout to webapi (#95) 2024-07-09 22:07:44 +02:00
Leendert de Borst
836f7a311e Add timeout to favicon extraction (#95) (#96) 2024-07-09 21:54:49 +02:00
Leendert de Borst
a3b232543f Add timeout to favicon extraction (#95) 2024-07-09 21:54:27 +02:00
Leendert de Borst
6077f8f377 Merge pull request #94 from lanedirt/93-username-field-value-not-shown-in-view-page
Fix username field value
2024-07-09 12:35:48 -07:00
Leendert de Borst
62711f603b Fix username field value (#93) 2024-07-09 21:35:29 +02:00
Leendert de Borst
a59fa22fa6 Merge pull request #91 from lanedirt/74-add-versioning-support-to-local-sqlite-implementation-with-local-upgrade-paths
Add versioning support to local sqlite implementation with local upgrade paths
2024-07-09 12:19:10 -07:00
Leendert de Borst
cc10bcdfb8 Add asserts to test (#74) 2024-07-09 21:11:49 +02:00
Leendert de Borst
481c283c36 Update PlaywrightTest.cs (#74) 2024-07-09 00:50:57 +02:00
Leendert de Borst
947988c2ce Add empty test migration to make the E2E vault upgrade test work (#74) 2024-07-09 00:45:55 +02:00
Leendert de Borst
202df3d9c3 Update DbUpgradeTest.cs (#74) 2024-07-09 00:29:16 +02:00
Leendert de Borst
8f3ad3d171 Add E2E test scaffolding with seed data (#74) 2024-07-09 00:21:15 +02:00
Leendert de Borst
aa771ae1b2 Fix sync redirect (#74) 2024-07-08 21:43:19 +02:00
Leendert de Borst
158a526aee Fix auth revoke token call (#74) 2024-07-08 16:45:05 +02:00
Leendert de Borst
8c4e078490 Fix tests to after adding new new sync page (#74) 2024-07-08 16:40:34 +02:00
Leendert de Borst
98dea2c4bf Improve client DB sync status indicators (#74) 2024-07-08 16:31:24 +02:00
Leendert de Borst
db62eeec22 Add client DB migration screen (#74) 2024-07-08 12:55:12 +02:00
Leendert de Borst
af1e813c48 Rename initial migration to include explicit version number (#74) 2024-07-08 11:54:09 +02:00
Leendert de Borst
d57ac9e743 Update E2E sleep threshold to 100ms to prevent ERR_CONNECTION_REFUSED (#74) 2024-07-08 11:53:47 +02:00
Leendert de Borst
f5e02fb784 Add register warning test (#74) 2024-07-08 11:26:01 +02:00
Leendert de Borst
96a7fbaf3b Add vault decrypt error message (#74) 2024-07-08 11:25:50 +02:00
Leendert de Borst
c749853870 Merge pull request #90 from lanedirt/83-refactor-wasm-project-for-naming-conventions-and-fix-remaining-todos 2024-07-03 14:39:38 -07:00
Leendert de Borst
8c93fceb3e Make E2E test more robust (#83) 2024-07-03 22:41:43 +02:00
Leendert de Borst
fb0ef1c59a Generic refactoring, delete unused files, folder restructuring (#83) 2024-07-03 20:08:18 +02:00
Leendert de Borst
847d97a0e9 Merge pull request #87 from lanedirt/81-add-support-for-attachments-file-upload 2024-07-02 23:39:18 -07:00
Leendert de Borst
8d9c80ef61 Fix multiple attachment add bug (#81) 2024-07-03 00:27:16 +02:00
Leendert de Borst
c22ba0c2cf Add DbPersist E2E test (#81) 2024-07-02 23:59:00 +02:00
Leendert de Borst
5e4654a968 Update DbService.cs (#81) 2024-07-02 23:23:23 +02:00
Leendert de Borst
bbf08d16d4 Update AttachmentViewer.cs (#81) 2024-07-02 23:21:30 +02:00
Leendert de Borst
19c6296a86 Fix tests (#81) 2024-07-02 23:20:03 +02:00
Leendert de Borst
b8ffd39f99 Revert encryption key debug statement (#81) 2024-07-02 23:15:38 +02:00
Leendert de Borst
fe29cb7a2c Merge branch 'main' into 81-add-support-for-attachments-file-upload 2024-07-02 14:13:05 -07:00
Leendert de Borst
4137cc4736 Add attachment uploader/viewer component (#81) 2024-07-02 23:11:34 +02:00
Leendert de Borst
3d23731f0e Merge pull request #86 from lanedirt/84-add-exportimport-database-to-unencrypted-csv
Add CSV import/export and DB loader (#84)
2024-07-02 14:09:45 -07:00
Leendert de Borst
bcbda92601 Update tests (#84) 2024-07-02 21:58:39 +02:00
Leendert de Borst
f0fca573fd Updated vault settings page layout, refactor tests (#84) 2024-07-02 21:22:39 +02:00
Leendert de Borst
4abc674970 Add CSV import/export and DB loader (#84) 2024-07-02 20:49:53 +02:00
Leendert de Borst
43ed35a1be Merge pull request #79 from lanedirt/58-migrate-existing-alias-crud-pages-to-new-local-sqlite-implementation
Refactor CRUD pages to local SQLite model
2024-07-01 15:50:16 -07:00
Leendert de Borst
689ab0b388 Update CredentialService.cs (#58) 2024-07-02 00:46:35 +02:00
Leendert de Borst
a884895fae Restored favicon extraction logic (#58) 2024-07-02 00:05:40 +02:00
Leendert de Borst
a644df1e3c Add notes and birthdate field, add date field validation (#58) 2024-07-01 17:18:42 +02:00
Leendert de Borst
48abe09415 Rename login to credentials, fixed warnings and bugs (#58) 2024-07-01 15:43:32 +02:00
Leendert de Borst
f1bc79a9a4 Refactor (#58) 2024-07-01 13:45:07 +02:00
Leendert de Borst
ce61fc36e1 Refactor SpamOK webapi model to eliminate duplicated code (#58) 2024-07-01 13:02:29 +02:00
Leendert de Borst
cbce527aa1 Delete AliasController.cs old api endpoints (#58) 2024-07-01 12:49:11 +02:00
Leendert de Borst
53ea7c2477 Remove client tables from server db (#58) 2024-07-01 12:46:50 +02:00
Leendert de Borst
aae0846639 Refactor CRUD pages to local SQLite model (#58) 2024-06-30 17:58:01 +02:00
Leendert de Borst
c2648cf2cb Merge pull request #76 from lanedirt/63-abstract-auth-loginunlock-to-share-the-same-implementation-to-reduce-duplicate-code
Abstract Login and Unlock duplicated logic (#63)
2024-06-29 14:23:21 -07:00
Leendert de Borst
68c19957e0 Add webapi call finish content to test pages for improved stability (#63) 2024-06-29 23:12:51 +02:00
Leendert de Borst
6c79503d1f Fix unittests (#63) 2024-06-29 23:00:41 +02:00
Leendert de Borst
c5fc5c0e81 Add dedicated test pages to webapi and wasm for E2E tests (#63) 2024-06-29 22:57:19 +02:00
Leendert de Borst
4170e754ea Add JWT auth tokens E2E tests (#63) 2024-06-29 13:07:06 +02:00
Leendert de Borst
5d44a3aeff Fix missing redirect in login handler (#63) 2024-06-29 11:23:27 +02:00
Leendert de Borst
8243213028 Abstract Login and Unlock duplicated logic (#63) 2024-06-28 23:33:33 +02:00
Leendert de Borst
efc422ac44 Merge pull request #75 from lanedirt/69-implement-vault-changebackup-history-logic-on-server
Implement vault changebackup history logic on server
2024-06-28 14:32:01 -07:00
Leendert de Borst
497430f729 Refactor (#69) 2024-06-28 23:20:20 +02:00
Leendert de Borst
659dc7b55d Refactor DbService client logic (#69) 2024-06-28 20:04:26 +02:00
Leendert de Borst
581fd945c2 Update VaultRetention logic and add tests (#69) 2024-06-28 20:04:06 +02:00
Leendert de Borst
8205fa9d6e Add vault retention logic (#69) 2024-06-28 12:29:45 +02:00
Leendert de Borst
cc4a6db976 Merge pull request #71 from lanedirt/55-add-client-side-database-sync-indicator-to-ui
Add client side database sync indicator to UI
2024-06-28 02:28:24 -07:00
Leendert de Borst
efb7ae009d Remove unused vars (#55) 2024-06-28 10:56:12 +02:00
Leendert de Borst
f29606ea94 Update UI style (#55) 2024-06-28 10:47:38 +02:00
Leendert de Borst
4a586cf117 Add DbStatus indicator to UI (#55) 2024-06-28 10:05:50 +02:00
Leendert de Borst
64688cd2b5 Merge pull request #68 from lanedirt/67-improve-e2e-test-stability
Change port assignment logic (#67)
2024-06-26 15:24:16 -07:00
Leendert de Borst
ea9c6e9aa7 Update Program.cs (#67) 2024-06-27 00:07:33 +02:00
Leendert de Borst
889dc81404 Add host project for in-memory testing of WASM app (#67) 2024-06-26 18:53:47 +02:00
Leendert de Borst
df85846fbe Change port assignment logic (#67) 2024-06-26 08:55:26 +02:00
Leendert de Borst
5a9632f80e Merge pull request #66 from lanedirt/60-unlock-screen-can-be-bypassed-by-clicking-on-logo
Add unlock page redirect test (#60)
2024-06-25 15:02:30 -07:00
Leendert de Borst
9105215dc8 Add unlock page redirect test (#60) 2024-06-25 23:55:39 +02:00
Leendert de Borst
4aa57f95b5 Merge pull request #65 from lanedirt/60-unlock-screen-can-be-bypassed-by-clicking-on-logo
Add encryption key check to generic PageBase (#60)
2024-06-25 14:40:19 -07:00
Leendert de Borst
1112922731 Add assert to UnlockTest (#60) 2024-06-25 23:33:41 +02:00
Leendert de Borst
2bee131ff4 Add E2E test for unlock mechanism (#60) 2024-06-25 23:26:54 +02:00
Leendert de Borst
75933efbdd Refactor isEncryptionKeySet logic to prevent delayed navigation loops (#60) 2024-06-25 23:03:06 +02:00
Leendert de Borst
93623a2f05 Update AliasClientDbService.cs (#60) 2024-06-25 22:35:11 +02:00
Leendert de Borst
4b12518ee4 Fix auth state redirect loop (#60) 2024-06-25 21:56:46 +02:00
Leendert de Borst
2a834eeb38 Add encryption key check to generic PageBase (#60) 2024-06-25 21:40:57 +02:00
Leendert de Borst
25872f08de Merge pull request #64 from lanedirt/59-save-encryption-key-to-session-on-registration-next-to-login
Add missing store calls (#59)
2024-06-24 11:05:29 -07:00
Leendert de Borst
9fc0f4d7da Merge pull request #62 from lanedirt/61-move-aliasclientdb-project-to-correct-folder
Move AliasClientDb project to correct folder (#61)
2024-06-24 10:39:35 -07:00
Leendert de Borst
4e2bf10115 Add missing store calls (#59) 2024-06-24 19:37:48 +02:00
Leendert de Borst
957a9474ec Move AliasClientDb project to correct folder (#61) 2024-06-24 19:32:38 +02:00
Leendert de Borst
bd0d4ad2a4 Merge pull request #56 from lanedirt/44-change-datamodel-to-be-more-dynamic-and-support-client-side-encryption
44 change datamodel to be more dynamic and support client side encryption
2024-06-24 10:09:20 -07:00
Leendert de Borst
88fa8a0c17 Update Dockerfile (#44) 2024-06-24 19:04:54 +02:00
Leendert de Borst
943f16789f Update docker compose (#44) 2024-06-24 18:34:01 +02:00
Leendert de Borst
bb91637db5 Refactoring (#44) 2024-06-24 17:38:58 +02:00
Leendert de Borst
5aef4c58e2 Add working version of client-side SQLite sync via webapi (#44) 2024-06-24 17:15:19 +02:00
Leendert de Borst
554ea91bda Add SQLite in-memory load/save mechanism (#44) 2024-06-24 14:35:45 +02:00
Leendert de Borst
d5a858d78d Refactor EF database projects, added basic client-side SQLite in-memory implementation (#44) 2024-06-24 11:30:37 +02:00
Leendert de Borst
ff265c3a86 Remove AliasVault Blazor server project (#44) 2024-06-24 09:10:20 +02:00
Leendert de Borst
f2fced86b2 Merge pull request #53 from lanedirt/52-fix-ide-warning-configuration-in-rider-regardless-of-user-settings
Move dotnet code style settings to .globalconfig (#52)
2024-06-23 12:44:26 -07:00
Leendert de Borst
83f62e17b2 Update Dockerfile (#52) 2024-06-23 21:35:42 +02:00
Leendert de Borst
0f80217e74 Replace buildtask project with RoslynCodeTaskFactory implementation (#52) 2024-06-23 21:33:56 +02:00
Leendert de Borst
7290ee870c Update buildtasks so it works on both Windows and Mac/Linux (#52) 2024-06-23 20:56:39 +02:00
Leendert de Borst
27c0c9194e Add custom build task for cache busting (#52) 2024-06-23 20:13:56 +02:00
Leendert de Borst
ae652297fa Update Login.razor (#52) 2024-06-23 19:49:11 +02:00
Leendert de Borst
bcdb9efee8 Move dotnet code style settings to .globalconfig (#52) 2024-06-23 19:41:30 +02:00
Leendert de Borst
f224507f91 Merge pull request #51 from lanedirt/49-fix-docker-build-ef-migrations
Update docker-compose.build.yml (#49)
2024-06-23 10:28:38 -07:00
Leendert de Borst
dc9ba64b21 Refactor dockerfiles to remove explicit EF bundle (#49) 2024-06-23 19:22:03 +02:00
Leendert de Borst
c99394416e Update docker-compose-build.yml typo (#49) 2024-06-23 18:31:05 +02:00
Leendert de Borst
ba2abde97d Update RootController.cs (#49) 2024-06-23 18:27:20 +02:00
Leendert de Borst
66b33a8686 Add healthcheck webapi endpoint (#49) 2024-06-23 18:24:33 +02:00
Leendert de Borst
1ebdce5216 Update docker-compose.build.yml (#49) 2024-06-23 18:03:42 +02:00
Leendert de Borst
c2d1ea9895 Merge pull request #50 from lanedirt/49-fix-docker-build-ef-migrations
49 fix docker build ef migrations
2024-06-23 09:00:50 -07:00
Leendert de Borst
75a9278d56 Update .gitignore (#49) 2024-06-23 17:55:53 +02:00
Leendert de Borst
b508354ac6 Remove .env from git (#49) 2024-06-23 17:53:50 +02:00
Leendert de Borst
e1478c055f Merge pull request #48 from lanedirt/43-implement-master-password-for-login-and-basic-encryption
Implement master password for login and basic encryption
2024-06-23 08:28:37 -07:00
Leendert de Borst
c07f0c33bb Update AuthController.cs (#43) 2024-06-23 17:22:17 +02:00
Leendert de Borst
4e2b10eeab Add try/catch to E2E test init (#43) 2024-06-23 17:12:35 +02:00
Leendert de Borst
05e9285752 Code style refactor (#43) 2024-06-23 16:35:45 +02:00
Leendert de Borst
a76a21a935 Update TestDefaults.cs due to Argon2id speed in WASM (#43) 2024-06-21 19:28:20 +02:00
Leendert de Borst
0fd23eab59 Fix code style issues (#43) 2024-06-21 19:27:54 +02:00
Leendert de Borst
1c1d1e1d74 Fix JWT key retrieval (#43) 2024-06-21 17:38:51 +02:00
Leendert de Borst
9412f862eb Make Argon2id dynamic using SRP salt (#43) 2024-06-21 15:52:08 +02:00
Leendert de Borst
9f7ba2eb20 Implement SPR for login flow (#43) 2024-06-21 15:37:51 +02:00
Leendert de Borst
101d1d485a Implement SPR for basic signup flow (#43) 2024-06-21 11:48:27 +02:00
Leendert de Borst
e316836ee5 Make basic SRP flow work (#43) 2024-06-21 10:44:09 +02:00
Leendert de Borst
c6e3c41759 Add SRP auth scaffolding (#43) 2024-06-21 00:52:19 +02:00
Leendert de Borst
bca7b2bc82 Merge pull request #41 from lanedirt/29-add-cache-busting-to-wasm-app-static-resources
Fix cache buster for release mode (#29)
2024-06-19 16:08:03 -07:00
Leendert de Borst
d51ae8d913 Fix cache buster for release mode (#29) 2024-06-20 00:56:23 +02:00
Leendert de Borst
c5d2b1da37 Merge pull request #40 from lanedirt/39-fix-init-script-colors-for-all-terminals
Update init.sh (#39)
2024-06-19 15:27:11 -07:00
Leendert de Borst
5d85b3a275 Update init.sh (#39) 2024-06-20 00:26:58 +02:00
Leendert de Borst
3ba8e54e56 Merge pull request #38 from lanedirt/33-refactor-jwt-keys-in-webapi-from-appsettingsjson-to-environment-variables
33 refactor jwt keys in webapi from appsettingsjson to environment variables
2024-06-19 15:13:13 -07:00
Leendert de Borst
7ffc1f1ee5 Fix E2E tests (#33) 2024-06-20 00:07:20 +02:00
Leendert de Borst
8d4024860b Add cache busting to index.html (#33) 2024-06-20 00:03:53 +02:00
Leendert de Borst
383145814a Add init.sh script for initial setup (#33) 2024-06-19 23:16:18 +02:00
Leendert de Borst
210f4b3c9e Add .env for JWT keys, minor refactoring (#33) 2024-06-19 21:51:54 +02:00
Leendert de Borst
276ceb3dce Merge pull request #36 from lanedirt/35-fix-code-style-issues
Fix code style issues
2024-06-18 15:07:18 -07:00
Leendert de Borst
2985c8333e Fix code style issues (#35) 2024-06-19 00:00:29 +02:00
Leendert de Borst
7bb8aee532 Add sonarcloud badges (#35) 2024-06-18 23:56:25 +02:00
Leendert de Borst
7de3b05985 Fix code style issues (#35) 2024-06-18 23:56:16 +02:00
Leendert de Borst
daca01a428 Merge pull request #34 from lanedirt/32-fix-linting-issues
Fix linting issues (#32)
2024-06-18 14:30:52 -07:00
Leendert de Borst
9fb19d28d6 Update sonarcloud-code-analysis (#32) 2024-06-18 23:21:20 +02:00
Leendert de Borst
540177c762 Fix linting issues (#32) 2024-06-18 23:18:16 +02:00
Leendert de Borst
228b037a6d Merge pull request #31 from lanedirt/30-add-sonarcloud-integration-for-code-analysis
Add sonarcloud github action
2024-06-18 14:04:29 -07:00
Leendert de Borst
0e0366564d Update CryptographyTests.cs (#30) 2024-06-18 22:56:42 +02:00
Leendert de Borst
cca91d6076 Update sonarcloud code analysis (#30) 2024-06-18 22:37:36 +02:00
Leendert de Borst
6c9e770af7 Update sonarcloud-code-analysis.yml (#30) 2024-06-18 22:01:38 +02:00
Leendert de Borst
44bcb7f16d Merge branch '30-add-sonarcloud-integration-for-code-analysis' of https://github.com/lanedirt/AliasVault into 30-add-sonarcloud-integration-for-code-analysis
* '30-add-sonarcloud-integration-for-code-analysis' of https://github.com/lanedirt/AliasVault:
  Add sonarcloud github action
2024-06-18 21:53:27 +02:00
Leendert de Borst
d69b3defe5 Add coverlet.msbuild (#30) 2024-06-18 21:53:26 +02:00
Leendert de Borst
02af26cb39 Add sonarcloud github action (#30) 2024-06-18 21:53:11 +02:00
Leendert de Borst
3cc3c67a4d Add sonarcloud github action 2024-06-18 21:41:28 +02:00
Leendert de Borst
107d2d8602 Merge pull request #28 from lanedirt/27-fix-wasm-topbar-menu-open-toggle-css-styles
WASM app tweaks for UI (#27)
2024-06-18 12:03:47 -07:00
Leendert de Borst
b8301d8f98 Merge pull request #26 from lanedirt/25-add-versioning-to-webapi-project
Add versioning to webapi project
2024-06-18 11:45:52 -07:00
Leendert de Borst
124491e5db WASM app tweaks for UI (#27) 2024-06-18 20:45:26 +02:00
Leendert de Borst
dbea1c2c4d Add api version prefix to WASM app URLs (#25) 2024-06-18 20:43:07 +02:00
Leendert de Borst
949a7a856a Add versioning to webapi (#25) 2024-06-18 20:41:21 +02:00
Leendert de Borst
b923669b66 Merge pull request #24 from lanedirt/23-add-form-validation-to-login-and-signup-pages
Add form validation to login/register pages
2024-06-17 13:11:32 -07:00
Leendert de Borst
da25aa43ea Add form validation to login/register pages (#23) 2024-06-17 22:08:49 +02:00
376 changed files with 11841 additions and 38294 deletions

View File

@@ -10,10 +10,7 @@ insert_final_newline = true
# C# files
[*.cs]
indent_style = space
indent_size = 4
csharp_indent_labels = one_less_than_current
csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent
csharp_style_namespace_declarations = block_scoped:silent
@@ -28,12 +25,6 @@ csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = false:silent
dotnet_diagnostic.SA1011.severity = none
dotnet_diagnostic.SA1101.severity = none
dotnet_diagnostic.SA1200.severity = none
dotnet_diagnostic.SA1309.severity = none
dotnet_diagnostic.SA1310.severity = warning
dotnet_diagnostic.SX1309.severity = none
# Razor files
[*.razor]
@@ -69,56 +60,3 @@ indent_size = 4
[*.xml]
indent_style = space
indent_size = 4
[*.{cs,vb}]
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
end_of_line = crlf
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion

1
.env.example Normal file
View File

@@ -0,0 +1 @@
JWT_KEY=

View File

@@ -15,6 +15,10 @@ jobs:
options: --privileged
steps:
- uses: actions/checkout@v2
- name: Set permissions and run init.sh
run: |
chmod +x init.sh
./init.sh
- name: Set up Docker Compose
run: |
# Build the images and start the services
@@ -37,8 +41,8 @@ jobs:
run: |
# Test if the service on localhost:81 responds
http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:81)
if [ "$http_code" -ne 200 ] && [ "$http_code" -ne 404 ]; then
echo "Service did not respond with expected 200 OK or 404 Not Found"
if [ "$http_code" -ne 200 ]; then
echo "Service did not respond with expected 200 OK. Check if all DB migrations are applied."
exit 1
else
echo "Service responded with $http_code"

View File

@@ -20,6 +20,8 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install dependencies
run: dotnet workload install wasm-tools
- name: Restore dependencies
run: dotnet restore
- name: Build

View File

@@ -19,6 +19,8 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Install dependencies
run: dotnet workload install wasm-tools
- name: Build
run: dotnet build
- name: Ensure browsers are installed

View File

@@ -0,0 +1,49 @@
name: SonarCloud code analysis
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
name: Build and analyze
runs-on: windows-latest
steps:
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: 17
distribution: 'zulu' # Alternative distribution options are available.
- uses: actions/checkout@v3
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Cache SonarCloud packages
uses: actions/cache@v3
with:
path: ~\sonar\cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Cache SonarCloud scanner
id: cache-sonar-scanner
uses: actions/cache@v3
with:
path: .\.sonar\scanner
key: ${{ runner.os }}-sonar-scanner
restore-keys: ${{ runner.os }}-sonar-scanner
- name: Install SonarCloud scanner
if: steps.cache-sonar-scanner.outputs.cache-hit != 'true'
shell: powershell
run: |
New-Item -Path .\.sonar\scanner -ItemType Directory
dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner
- name: Build and analyze
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
shell: powershell
run: |
.\.sonar\scanner\dotnet-sonarscanner begin /k:"lanedirt_AliasVault" /o:"lanedirt" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" /d:sonar.coverage.exclusions="**Tests*.cs"
dotnet build
dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutput=coverage /p:CoverletOutputFormat=opencover --filter 'FullyQualifiedName!~AliasVault.E2ETests'
.\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"

11
.gitignore vendored
View File

@@ -370,3 +370,14 @@ FodyWeavers.xsd
.idea
*.licenseheader
# AliasVault specific
# index.html is generated by the build process from index.template.html and therefore should be ignored
src/AliasVault.WebApp/wwwroot/index.html
# appsettings.Development.json is generated by the build process from appsettings.Development.template.json and therefore should be ignored
src/AliasVault.WebApp/wwwroot/appsettings.Development.json
# appsettings.Development.json is added manually if needed, it should not be committed.
src/Tests/AliasVault.E2ETests/appsettings.Development.json
# .env is generated by init.sh and therefore should be ignored
.env

57
.globalconfig Normal file
View File

@@ -0,0 +1,57 @@
dotnet_diagnostic.SA1011.severity = none
dotnet_diagnostic.SA1101.severity = none
dotnet_diagnostic.SA1309.severity = none
dotnet_diagnostic.SA1310.severity = warning
dotnet_diagnostic.SX1309.severity = none
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_coalesce_expression = false:suggestion
dotnet_style_null_propagation = false:suggestion
# IDE0046: Convert to conditional expression
dotnet_diagnostic.IDE0046.severity = silent

19
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,19 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "C#: AliasVault.WebApp [http]",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/AliasVault.WebApp/AliasVault.WebApp.csproj",
"launchConfigurationId": "TargetFramework=;http"
},
{
"name": "C#: AliasVault.Api [http]",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/AliasVault.Api/AliasVault.Api.csproj",
"launchConfigurationId": "TargetFramework=;http"
}
]
}

View File

@@ -68,3 +68,21 @@ dotnet tool install --global Microsoft.Playwright.CLI
# Note: make sure the E2E test project has been built at least once so the bin dir exists.
pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install
```
### 7. Create AliasVault.WebApp appsettings.Development.json
The WASM app supports a development specific appsettings.json file. This appsettings file is optional but can override various options to make debugging easier.
1. Copy `wwwroot/appsettings.json` to `wwwroot/appsettings.Development.json`
Here is an example file with the various options explained:
```
{
"ApiUrl": "http://localhost:5092",
"UseDebugEncryptionKey": "true"
}
```
- UseDebugEncryptionKey
- This setting will use a static encryption key so that if you login as a user you can refresh the page without needing to unlock the database again. This speeds up development when changing things in the WebApp WASM project. Note: the project needs to be run in "Development" mode for this setting to be used.

13
ENCRYPTION.md Normal file
View File

@@ -0,0 +1,13 @@
# Encryption
This document describes the encryption used in AliasVault.
## SRP
The application uses the Secure Remote Password (SRP) protocol for authentication. The SRP protocol is a password-authenticated key agreement protocol. This means that the client and server can authenticate each other using a password, without sending the password over the network.
With the use of SRP the master password never leaves the client. The client sends a verifier to the server, which is a value derived from the master password. The server uses this verifier to authenticate the client. With this the server can authenticate the client without having ever seen the actual master password.
## Argon2id
The application uses the Argon2id key derivation function to derive a key from the master password. Argon2id is a memory-hard function, which makes it difficult to perform large-scale custom hardware attacks. This makes it a good choice for password hashing.
## AES
AES-256 IV is used to encrypt the data. The data is encrypted with a key derived from the master password using Argon2id. The Initialization Vector (IV) is generated randomly for each encryption.

View File

@@ -1,47 +1,83 @@
<div align="center">
<h1>AliasVault</h1>
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/OGameX/releases)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/docker-compose-build.yml?label=docker-compose%20build">](https://github.com/lanedirt/AliasVault/actions/workflows/docker-compose-build.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-build-run-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/OGameX/releases)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/docker-compose-build.yml?label=docker-compose%20build">](https://github.com/lanedirt/AliasVault/actions/workflows/docker-compose-build.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-build-run-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
[<img src="https://img.shields.io/sonar/coverage/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=test code coverage">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
[<img src="https://img.shields.io/sonar/quality_gate/lanedirt_AliasVault?server=https%3A%2F%2Fsonarcloud.io&label=sonarcloud&logo=sonarcloud">](https://sonarcloud.io/summary/new_code?id=lanedirt_AliasVault)
</div>
> Disclaimer: This repository is currently in an alpha state and is NOT ready for production use. Critical features, such as encryption, are not yet fully implemented. AliasVault is a work in progress and as of this moment serves as a research playground. Users are welcome to explore and use this project, but please be aware that there are no guarantees regarding its security or stability. Use at your own risk!
AliasVault is an open-source password and identity manager built with C# ASP.NET technology. AliasVault can be self-hosted on your own server with Docker, providing a secure and private solution for managing your online identities and passwords.
AliasVault is an open-source password manager that can generate virtual identities complete with virtual email addresses. AliasVault can be self-hosted on your own server with Docker, providing a secure and private solution for managing your online identities and passwords.
### What makes AliasVault unique:
- **Zero-knowledge architecture**: All data is end-to-end encrypted on the client and stored in encrypted state on the server. Your master password never leaves your device and the server never has access to your data.
- **Virtual identities**: Generate virtual identities with virtual (working) email addresses that are assigned to one or more passwords.
- **Open-source**: The source code is available on GitHub and can be self-hosted on your own server.
## Features
- Password Management: Securely store and manage your passwords.
- Virtual Identities: Generate virtual identities with virtual (working) email addresses.
- Data Protection: Ensures that all sensitive data is encrypted and securely stored.
- User Authentication: Secure login and user management functionalities.
> Note: AliasVault is currently in development and not yet ready for production use. The project is still in the early stages and many features are not yet implemented. You are welcome to contribute to the project by submitting pull requests or opening issues.
## Installation
## Live demo
A live demo of the app is available at [main.aliasvault.net](https://main.aliasvault.net) (nightly builds). You can create a free account to try it out yourself.
1. Clone this repository.
<img width="700" alt="Screenshot 2024-07-12 at 14 58 29" src="https://github.com/user-attachments/assets/57103f67-dff0-4124-9b33-62137aab5578">
## Installation on your own machine
To install AliasVault on your own machine, follow the steps below. Note: the install process is tested on MacOS and Linux. It should work on Windows too, but you might need to adjust some commands.
### Requirements:
- Access to a terminal
- Docker
- Git
### 1. Clone this repository.
```bash
git clone [URL]
# Clone this Git repository to "AliasVault" directory
$ git clone https://github.com/lanedirt/AliasVault.git
```
2. Run the app via Docker:
### 2. Run the init script.
This script will create a .env file in the root directory of the project if it does not yet exist and populate it with a random encryption secret.
```bash
# Go to the project directory
$ cd AliasVault
# Make init script executable
$ chmod +x init.sh
# Run the init script
$ ./init.sh
```
### 3. Build and run the app via Docker:
```bash
docker compose up -d --build --force-recreate
# Build and run the app via Docker Compose
$ docker compose up -d --build --force-recreate
```
> Note: the container binds to port 80 by default. If you have another service running on port 80, you can change the port in the `docker-compose.yml` file.
The app will be available at http://localhost:80
#### Note for first time build:
- When running the docker compose command for the first time, it may take a few minutes to build the Docker image.
- A SQLite database file will be created in `./database/AliasServerDb.sqlite`. This file will store all (encrypted) password vaults. It should be kept secure and not shared.
After the Docker containers have started the app will be available at http://localhost:80
## Tech stack / credits
The following technologies, frameworks and libraries are used in this project:
## Credits
The following libraries and frameworks are used in this project:
- [C#](https://docs.microsoft.com/en-us/dotnet/csharp/) - A simple, modern, object-oriented, and type-safe programming language.
- [ASP.NET Core](https://dotnet.microsoft.com/apps/aspnet) - An open-source framework for building modern, cloud-based, internet-connected applications.
- [Blazor](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor) - A framework for building interactive web UIs using C# instead of JavaScript.
- [Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/) - A lightweight, extensible, open-source and cross-platform version of the popular Entity Framework data access technology.
- [Blazor WASM](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor) - A framework for building interactive web UIs using C# instead of JavaScript. It's a single-page app framework that runs in the browser via WebAssembly.
- [Playwright](https://playwright.dev/) - A Node.js library to automate Chromium, Firefox and WebKit with a single API. Used for end-to-end testing.
- [Docker](https://www.docker.com/) - A platform for building, sharing, and running containerized applications.
- [SQLite](https://www.sqlite.org/index.html) - A C-language library that implements a small, fast, self-contained, high-reliability, full-featured, SQL database engine.
- [Tailwind CSS](https://tailwindcss.com/) - A utility-first CSS framework for rapidly building custom designs.
- [Flowbite](https://flowbite.com/) - A free and open-source UI component library.
- [Flowbite](https://flowbite.com/) - A free and open-source UI component library based on Tailwind CSS.
- [Konscious.Security.Cryptography](https://github.com/kmaragon/Konscious.Security.Cryptography) - A .NET library that implements Argon2id, a memory-hard password hashing algorithm.
- [SRP.net](https://github.com/secure-remote-password/srp.net) - SRP6a Secure Remote Password protocol for secure password authentication.
- [SqliteWasmHelper](https://github.com/JeremyLikness/SqliteWasmHelper) - The AliasVault SQLite WASM implementation is loosely based on this library.

View File

@@ -3,10 +3,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.10.34928.147
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault", "src\AliasVault\AliasVault.csproj", "{BD2050C0-DC26-4777-9514-546525307370}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasDb", "src\AliasDb\AliasDb.csproj", "{64F47C9A-FE69-4793-B469-28BAADEC6706}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasGenerators", "src\AliasGenerators\AliasGenerators.csproj", "{78E84B4E-57D1-491A-8F4E-9879AE49DE0F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utilities", "Utilities", "{01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}"
@@ -27,20 +23,24 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{29DE523D
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.E2ETests", "src\Tests\AliasVault.E2ETests\AliasVault.E2ETests.csproj", "{AF013D08-1BF6-4E23-87D2-37F614BE7952}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Database", "Database", "{5F7417F6-4388-49CC-9511-ED63C4A6488A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasServerDb", "src\Databases\AliasServerDb\AliasServerDb.csproj", "{1277105D-50CD-4CE0-9C2C-549F46867E54}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasClientDb", "src\Databases\AliasClientDb\AliasClientDb.csproj", "{FE10F294-817F-477E-A24F-8597A15AF0B5}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.E2ETests.WebApp.Server", "src\Tests\Server\AliasVault.E2ETests.WebApp.Server\AliasVault.E2ETests.WebApp.Server.csproj", "{DD1F496F-CF10-47D1-A57F-5FA256479332}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Server", "Server", "{607945F3-9896-4544-99EC-F3496CF4D36B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsvImportExport", "src\Utilities\CsvImportExport\CsvImportExport.csproj", "{A9C9A606-C87E-4298-AB32-09B1884D7487}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{BD2050C0-DC26-4777-9514-546525307370}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BD2050C0-DC26-4777-9514-546525307370}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BD2050C0-DC26-4777-9514-546525307370}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BD2050C0-DC26-4777-9514-546525307370}.Release|Any CPU.Build.0 = Release|Any CPU
{64F47C9A-FE69-4793-B469-28BAADEC6706}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{64F47C9A-FE69-4793-B469-28BAADEC6706}.Debug|Any CPU.Build.0 = Debug|Any CPU
{64F47C9A-FE69-4793-B469-28BAADEC6706}.Release|Any CPU.ActiveCfg = Release|Any CPU
{64F47C9A-FE69-4793-B469-28BAADEC6706}.Release|Any CPU.Build.0 = Release|Any CPU
{78E84B4E-57D1-491A-8F4E-9879AE49DE0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{78E84B4E-57D1-491A-8F4E-9879AE49DE0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{78E84B4E-57D1-491A-8F4E-9879AE49DE0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -73,6 +73,22 @@ Global
{AF013D08-1BF6-4E23-87D2-37F614BE7952}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AF013D08-1BF6-4E23-87D2-37F614BE7952}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AF013D08-1BF6-4E23-87D2-37F614BE7952}.Release|Any CPU.Build.0 = Release|Any CPU
{1277105D-50CD-4CE0-9C2C-549F46867E54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1277105D-50CD-4CE0-9C2C-549F46867E54}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1277105D-50CD-4CE0-9C2C-549F46867E54}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1277105D-50CD-4CE0-9C2C-549F46867E54}.Release|Any CPU.Build.0 = Release|Any CPU
{FE10F294-817F-477E-A24F-8597A15AF0B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FE10F294-817F-477E-A24F-8597A15AF0B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE10F294-817F-477E-A24F-8597A15AF0B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FE10F294-817F-477E-A24F-8597A15AF0B5}.Release|Any CPU.Build.0 = Release|Any CPU
{DD1F496F-CF10-47D1-A57F-5FA256479332}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DD1F496F-CF10-47D1-A57F-5FA256479332}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DD1F496F-CF10-47D1-A57F-5FA256479332}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DD1F496F-CF10-47D1-A57F-5FA256479332}.Release|Any CPU.Build.0 = Release|Any CPU
{A9C9A606-C87E-4298-AB32-09B1884D7487}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9C9A606-C87E-4298-AB32-09B1884D7487}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9C9A606-C87E-4298-AB32-09B1884D7487}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9C9A606-C87E-4298-AB32-09B1884D7487}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -82,5 +98,13 @@ Global
{8E6A418A-B305-465D-857D-49953605C18E} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
{427EA8E2-EA76-467E-A6BC-201EFE40C0D0} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
{AF013D08-1BF6-4E23-87D2-37F614BE7952} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
{1277105D-50CD-4CE0-9C2C-549F46867E54} = {5F7417F6-4388-49CC-9511-ED63C4A6488A}
{FE10F294-817F-477E-A24F-8597A15AF0B5} = {5F7417F6-4388-49CC-9511-ED63C4A6488A}
{DD1F496F-CF10-47D1-A57F-5FA256479332} = {607945F3-9896-4544-99EC-F3496CF4D36B}
{607945F3-9896-4544-99EC-F3496CF4D36B} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
{A9C9A606-C87E-4298-AB32-09B1884D7487} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FEE82475-C009-4762-8113-A6563D9DC49E}
EndGlobalSection
EndGlobal

View File

@@ -10,17 +10,6 @@ services:
environment:
- API_URL=http://localhost:81
server:
image: aliasvault-server
build:
context: .
dockerfile: src/AliasVault/Dockerfile
ports:
- "82:8082"
volumes:
- ./database:/database
restart: always
api:
image: aliasvault-api
build:
@@ -30,4 +19,6 @@ services:
- "81:8081"
volumes:
- ./database:/database
env_file:
- .env
restart: always

5
docs/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Documentation
This is the documentation for the AliasVault project.
## Description
TODO: Work in progress.

View File

@@ -0,0 +1,18 @@
To configure SQLite for use with WebAssembly follow these steps:
1. Add NuGet package
```
dotnet add package SQLitePCLRaw.bundle_e_sqlite3
```
2. Modify .csproj and add the following:
```xml
<PropertyGroup>
<WasmBuildNative>true</WasmBuildNative>
</PropertyGroup>
```
3. Make sure the "wasm-tools" workload is installed on the local machine in order to build the project:
```
dotnet workload install wasm-tools
```

View File

@@ -0,0 +1,12 @@
To upgrade the AliasClientDb EF model, follow these steps:
1. Make changes to the AliasClientDb EF model in the `AliasClientDb` project.
2. Create a new migration by running the following command in the `AliasClientDb` project:
```bash
# Important: make sure the migration name is prefixed by the Semver version number of the release.
# For example, if the release version is 1.0.0, the migration name should be `1.0.0-<migration-name>`.
dotnet ef migrations add "1.0.0-<migration-name>"
```
4. On the next login of a user, they will be prompted (required) to upgrade their database schema to the latest version.
Make sure to manually test this.

78
init.sh Executable file
View File

@@ -0,0 +1,78 @@
#!/bin/sh
# Define colors for CLI output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Define the path to the .env and .env.example files
ENV_FILE=".env"
ENV_EXAMPLE_FILE=".env.example"
# Function to generate a new 32-character JWT key
generate_jwt_key() {
dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | head -c 32
}
# Function to create .env file from .env.example if it doesn't exist
create_env_file() {
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$ENV_EXAMPLE_FILE" ]; then
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
printf "${GREEN}> .env file created from .env.example.${NC}\n"
else
touch "$ENV_FILE"
printf "${YELLOW}> .env file created as empty because .env.example was not found.${NC}\n"
fi
else
printf "${CYAN}> .env file already exists.${NC}\n"
fi
}
# Function to check and populate the .env file with JWT_KEY
populate_jwt_key() {
if ! grep -q "^JWT_KEY=" "$ENV_FILE" || [ -z "$(grep "^JWT_KEY=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
printf "${YELLOW}JWT_KEY not found or empty in $ENV_FILE. Generating a new JWT key...${NC}\n"
JWT_KEY=$(generate_jwt_key)
if grep -q "^JWT_KEY=" "$ENV_FILE"; then
awk -v key="$JWT_KEY" '/^JWT_KEY=/ {$0="JWT_KEY="key} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
else
printf "JWT_KEY=${JWT_KEY}" >> "$ENV_FILE\n"
fi
printf "${GREEN}> JWT_KEY has been added to $ENV_FILE.${NC}\n"
else
printf "${CYAN}> JWT_KEY already exists and has a value in $ENV_FILE.${NC}\n"
fi
}
# Function to print the CLI logo
print_logo() {
printf "${MAGENTA}\n"
printf "=========================================================\n"
printf " _ _ __ __ _ _ \n"
printf " /\ | (_) \ \ / / | | | \n"
printf " / \ | |_ __ _ __\ \ / /_ _ _ _| | |_\n"
printf " / /\ \ | | |/ _ / __\ \/ / _ | | | | | __|\n"
printf " / ____ \| | | (_| \__ \\ / (_| | |_| | | |_ \n"
printf " /_/ \_\_|_|\__,_|___/ \/ \__,_|\__,_|_|\__|\n"
printf "\n"
printf "=========================================================\n"
printf "${NC}\n"
}
# Run the functions and print status
print_logo
printf "${BLUE}Initializing AliasVault...${NC}\n"
create_env_file
populate_jwt_key
printf "${BLUE}Initialization complete.${NC}\n"
printf "\n"
printf "To build the images and start the containers, run the following command:\n"
printf "\n"
printf "${CYAN}$ docker compose up -d --build --force-recreate${NC}\n"
printf "\n"
printf "\n"

View File

@@ -12,7 +12,7 @@
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -15,6 +15,10 @@ public class FigIdentityGenerator : IIdentityGenerator
{
private static readonly HttpClient HttpClient = new();
private static readonly string Url = "https://api.identiteitgenerator.nl/generate/identity";
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <inheritdoc/>
public async Task<Identity.Models.Identity> GenerateRandomIdentityAsync()
@@ -23,10 +27,7 @@ public class FigIdentityGenerator : IIdentityGenerator
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var identity = JsonSerializer.Deserialize<Identity.Models.Identity>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
var identity = JsonSerializer.Deserialize<Identity.Models.Identity>(json, JsonSerializerOptions);
if (identity is null)
{

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -18,6 +18,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
@@ -33,9 +35,11 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AliasDb\AliasDb.csproj" />
<ProjectReference Include="..\AliasGenerators\AliasGenerators.csproj" />
<ProjectReference Include="..\AliasVault.Shared\AliasVault.Shared.csproj" />
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
<ProjectReference Include="..\Utilities\Cryptography\Cryptography.csproj" />
<ProjectReference Include="..\Utilities\FaviconExtractor\FaviconExtractor.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,258 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="AliasController.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Controllers;
using System.Globalization;
using AliasDb;
using AliasVault.Shared.Models.WebApi;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Identity = AliasVault.Shared.Models.WebApi.Identity;
using Service = AliasVault.Shared.Models.WebApi.Service;
/// <summary>
/// Alias controller for handling CRUD operations on the database for alias entities.
/// </summary>
/// <param name="context">DbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
public class AliasController(AliasDbContext context, UserManager<IdentityUser> userManager) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Get all alias items for the current user.
/// </summary>
/// <returns>List of aliases in JSON format.</returns>
[HttpGet("items")]
public async Task<IActionResult> GetItems()
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return Unauthorized();
}
// Logic to retrieve items for the user.
var aliases = await context.Logins
.Include(x => x.Identity)
.Include(x => x.Service)
.Where(x => x.UserId == user.Id)
.Select(x => new AliasListEntry
{
Id = x.Id,
Logo = x.Service.Logo,
Service = x.Service.Name ?? "n/a",
CreateDate = x.CreatedAt,
})
.ToListAsync();
return Ok(aliases);
}
/// <summary>
/// Get a single alias item by its ID.
/// </summary>
/// <param name="aliasId">ID of the alias.</param>
/// <returns>Alias object as JSON.</returns>
[HttpGet("{aliasId}")]
public async Task<IActionResult> GetAlias(Guid aliasId)
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return Unauthorized();
}
var aliasObject = await context.Logins
.Include(x => x.Passwords)
.Include(x => x.Identity)
.Include(x => x.Service)
.Where(x => x.Id == aliasId)
.Where(x => x.UserId == user.Id)
.Select(x => new Alias()
{
Service = new Service()
{
Name = x.Service.Name ?? "n/a",
Url = x.Service.Url,
LogoUrl = string.Empty,
CreatedAt = x.Service.CreatedAt,
UpdatedAt = x.Service.UpdatedAt,
},
Identity = new Identity()
{
NickName = x.Identity.NickName,
FirstName = x.Identity.FirstName,
LastName = x.Identity.LastName,
BirthDate = x.Identity.BirthDate.ToString("yyyy-MM-dd"),
Gender = x.Identity.Gender,
AddressStreet = x.Identity.AddressStreet,
AddressCity = x.Identity.AddressCity,
AddressState = x.Identity.AddressState,
AddressZipCode = x.Identity.AddressZipCode,
AddressCountry = x.Identity.AddressCountry,
Hobbies = x.Identity.Hobbies,
EmailPrefix = x.Identity.EmailPrefix,
PhoneMobile = x.Identity.PhoneMobile,
BankAccountIBAN = x.Identity.BankAccountIBAN,
CreatedAt = x.Identity.CreatedAt,
UpdatedAt = x.Identity.UpdatedAt,
},
Password = new AliasVault.Shared.Models.WebApi.Password()
{
Value = x.Passwords.First().Value ?? string.Empty,
Description = string.Empty,
CreatedAt = x.Passwords.First().CreatedAt,
UpdatedAt = x.Passwords.First().UpdatedAt,
},
CreateDate = x.CreatedAt,
LastUpdate = x.UpdatedAt,
})
.FirstAsync();
return Ok(aliasObject);
}
/// <summary>
/// Insert a new alias to the database.
/// </summary>
/// <param name="model">Alias model.</param>
/// <returns>ID of newly inserted alias.</returns>
[HttpPut("")]
public async Task<IActionResult> Insert([FromBody] Alias model)
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return Unauthorized();
}
var login = new Login
{
UserId = user.Id,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
Identity = new AliasDb.Identity()
{
NickName = model.Identity.NickName,
FirstName = model.Identity.FirstName,
LastName = model.Identity.LastName,
BirthDate = DateTime.Parse(model.Identity.BirthDate ?? "1900-01-01", new CultureInfo("en-US")),
Gender = model.Identity.Gender,
AddressStreet = model.Identity.AddressStreet,
AddressCity = model.Identity.AddressCity,
AddressState = model.Identity.AddressState,
AddressZipCode = model.Identity.AddressZipCode,
AddressCountry = model.Identity.AddressCountry,
Hobbies = model.Identity.Hobbies,
EmailPrefix = model.Identity.EmailPrefix,
PhoneMobile = model.Identity.PhoneMobile,
BankAccountIBAN = model.Identity.BankAccountIBAN,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
},
};
login.Passwords.Add(new AliasDb.Password()
{
Value = model.Password.Value,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
login.Service = new AliasDb.Service()
{
Name = model.Service.Name,
Url = model.Service.Url,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await context.Logins.AddAsync(login);
await context.SaveChangesAsync();
return Ok(login.Id);
}
/// <summary>
/// Update an existing alias entry in the database.
/// </summary>
/// <param name="aliasId">The alias ID to update.</param>
/// <param name="model">Alias model.</param>
/// <returns>ID of updated alias entry.</returns>
[HttpPost("{aliasId}")]
public async Task<IActionResult> Update(Guid aliasId, [FromBody] Alias model)
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return Unauthorized();
}
// Get the existing entry.
var login = await context.Logins
.Include(x => x.Identity)
.Include(x => x.Service)
.Include(x => x.Passwords)
.Where(x => x.Id == aliasId)
.Where(x => x.UserId == user.Id)
.FirstAsync();
login.UpdatedAt = DateTime.UtcNow;
login.Identity.NickName = model.Identity.NickName;
login.Identity.FirstName = model.Identity.FirstName;
login.Identity.LastName = model.Identity.LastName;
login.Identity.BirthDate = DateTime.Parse(model.Identity.BirthDate ?? "1900-01-01", new CultureInfo("en-US"));
login.Identity.Gender = model.Identity.Gender;
login.Identity.AddressStreet = model.Identity.AddressStreet;
login.Identity.AddressCity = model.Identity.AddressCity;
login.Identity.AddressState = model.Identity.AddressState;
login.Identity.AddressZipCode = model.Identity.AddressZipCode;
login.Identity.AddressCountry = model.Identity.AddressCountry;
login.Identity.Hobbies = model.Identity.Hobbies;
login.Identity.EmailPrefix = model.Identity.EmailPrefix;
login.Identity.PhoneMobile = model.Identity.PhoneMobile;
login.Identity.BankAccountIBAN = model.Identity.BankAccountIBAN;
login.Passwords.First().Value = model.Password.Value;
login.Passwords.First().UpdatedAt = DateTime.UtcNow;
login.Service.Name = model.Service.Name;
login.Service.Url = model.Service.Url;
login.Service.UpdatedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
return Ok(login.Id);
}
/// <summary>
/// Delete an existing alias entry from the database.
/// </summary>
/// <param name="aliasId">ID of the alias to delete.</param>
/// <returns>HTTP status code.</returns>
[HttpDelete("{aliasId}")]
public async Task<IActionResult> Delete(Guid aliasId)
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return Unauthorized();
}
var login = await context.Logins
.Where(x => x.Id == aliasId)
.Where(x => x.UserId == user.Id)
.FirstAsync();
context.Logins.Remove(login);
await context.SaveChangesAsync();
return Ok();
}
}

View File

@@ -11,39 +11,99 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using AliasDb;
using AliasServerDb;
using AliasVault.Shared.Models;
using AliasVault.Shared.Models.WebApi;
using AliasVault.Shared.Models.WebApi.Auth;
using AliasVault.Shared.Providers.Time;
using Asp.Versioning;
using Cryptography.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.IdentityModel.Tokens;
/// <summary>
/// Auth controller for handling authentication.
/// </summary>
/// <param name="context">AliasDbContext instance.</param>
/// <param name="context">AliasServerDbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
/// <param name="signInManager">SignInManager instance.</param>
/// <param name="configuration">IConfiguration instance.</param>
[Route("api/[controller]")]
/// <param name="cache">IMemoryCache instance for persisting SRP values during multi-step login process.</param>
/// <param name="timeProvider">ITimeProvider instance. This returns the time which can be mutated for testing..</param>
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
public class AuthController(AliasDbContext context, UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager, IConfiguration configuration) : ControllerBase
[ApiVersion("1")]
public class AuthController(AliasServerDbContext context, UserManager<AliasVaultUser> userManager, SignInManager<AliasVaultUser> signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider) : ControllerBase
{
/// <summary>
/// Error message for invalid email or password.
/// </summary>
public static readonly string[] InvalidEmailOrPasswordError = { "Invalid email or password. Please try again." };
/// <summary>
/// Login endpoint used to process login attempt using credentials.
/// </summary>
/// <param name="model">Login model.</param>
/// <returns>IActionResult.</returns>
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginModel model)
public async Task<IActionResult> Login([FromBody] LoginRequest model)
{
var user = await userManager.FindByEmailAsync(model.Email);
if (user != null && await userManager.CheckPasswordAsync(user, model.Password))
if (user == null)
{
var tokenModel = await GenerateNewTokenForUser(user);
return Ok(tokenModel);
return BadRequest(ServerValidationErrorResponse.Create(InvalidEmailOrPasswordError, 400));
}
return Unauthorized();
// Server creates ephemeral and sends to client
var ephemeral = Cryptography.Srp.GenerateEphemeralServer(user.Verifier);
// Store the server ephemeral in memory cache for Validate() endpoint to use.
cache.Set(model.Email, ephemeral.Secret, TimeSpan.FromMinutes(5));
return Ok(new LoginResponse(user.Salt, ephemeral.Public));
}
/// <summary>
/// Validate endpoint used to validate the client's proof and generate the server's proof.
/// </summary>
/// <param name="model">ValidateLoginRequest model.</param>
/// <returns>IActionResult.</returns>
[HttpPost("validate")]
public async Task<IActionResult> Validate([FromBody] ValidateLoginRequest model)
{
var user = await userManager.FindByEmailAsync(model.Email);
if (user == null)
{
return BadRequest(ServerValidationErrorResponse.Create(InvalidEmailOrPasswordError, 400));
}
if (!cache.TryGetValue(model.Email, out var serverSecretEphemeral) || !(serverSecretEphemeral is string))
{
return BadRequest(ServerValidationErrorResponse.Create(InvalidEmailOrPasswordError, 400));
}
try
{
var serverSession = Cryptography.Srp.DeriveSessionServer(
serverSecretEphemeral.ToString() ?? string.Empty,
model.ClientPublicEphemeral,
user.Salt,
model.Email,
user.Verifier,
model.ClientSessionProof);
// If above does not throw an exception., then the client's proof is valid, and we can issue the JWT token.
var tokenModel = await GenerateNewTokensForUser(user);
// Return server proof for optional client check and token.
return Ok(new ValidateLoginResponse(serverSession.Proof, tokenModel));
}
catch
{
return BadRequest(ServerValidationErrorResponse.Create(InvalidEmailOrPasswordError, 400));
}
}
/// <summary>
@@ -70,7 +130,7 @@ public class AuthController(AliasDbContext context, UserManager<IdentityUser> us
// Remove any existing refresh tokens for this user and device.
var deviceIdentifier = GenerateDeviceIdentifier(Request);
var existingToken = context.AspNetUserRefreshTokens.Where(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier).FirstOrDefault();
if (existingToken == null || existingToken.Value != tokenModel.RefreshToken || existingToken.ExpireDate < DateTime.Now)
if (existingToken == null || existingToken.Value != tokenModel.RefreshToken || existingToken.ExpireDate < timeProvider.UtcNow)
{
return Unauthorized("Refresh token expired");
}
@@ -87,8 +147,8 @@ public class AuthController(AliasDbContext context, UserManager<IdentityUser> us
UserId = user.Id,
DeviceIdentifier = deviceIdentifier,
Value = newRefreshToken,
ExpireDate = DateTime.Now.AddDays(30),
CreatedAt = DateTime.Now,
ExpireDate = timeProvider.UtcNow.AddDays(30),
CreatedAt = timeProvider.UtcNow,
});
await context.SaveChangesAsync();
@@ -137,10 +197,10 @@ public class AuthController(AliasDbContext context, UserManager<IdentityUser> us
/// <param name="model">Register model.</param>
/// <returns>IActionResult.</returns>
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterModel model)
public async Task<IActionResult> Register([FromBody] SrpSignup model)
{
var user = new IdentityUser { UserName = model.Email, Email = model.Email };
var result = await userManager.CreateAsync(user, model.Password);
var user = new AliasVaultUser { UserName = model.Email, Email = model.Email, Salt = model.Salt, Verifier = model.Verifier };
var result = await userManager.CreateAsync(user);
if (result.Succeeded)
{
@@ -148,55 +208,63 @@ public class AuthController(AliasDbContext context, UserManager<IdentityUser> us
await signInManager.SignInAsync(user, isPersistent: false);
// Return the token.
var tokenModel = await GenerateNewTokenForUser(user);
var tokenModel = await GenerateNewTokensForUser(user);
return Ok(tokenModel);
}
else
var errors = result.Errors.Select(e => e.Description).ToArray();
return BadRequest(ServerValidationErrorResponse.Create(errors, 400));
}
/// <summary>
/// Generate a device identifier based on request headers. This is used to associate refresh tokens
/// with a specific device for a specific user.
///
/// 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.
/// </summary>
/// <param name="request">The HttpRequest instance for the request that the client used.</param>
/// <returns>Unique device identifier as string.</returns>
private static string GenerateDeviceIdentifier(HttpRequest request)
{
var userAgent = request.Headers.UserAgent.ToString();
var acceptLanguage = request.Headers.AcceptLanguage.ToString();
var rawIdentifier = $"{userAgent}|{acceptLanguage}";
return rawIdentifier;
}
/// <summary>
/// Get the JWT key from the environment variables.
/// </summary>
/// <returns>JWT key as string.</returns>
/// <exception cref="KeyNotFoundException">Thrown if environment variable does not exist.</exception>
private static string GetJwtKey()
{
var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY");
if (jwtKey is null)
{
return BadRequest(result.Errors);
throw new KeyNotFoundException("JWT_KEY environment variable is not set.");
}
return jwtKey;
}
private string GenerateJwtToken(IdentityUser user)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id ?? string.Empty),
new(ClaimTypes.Name, user.UserName ?? string.Empty),
new(ClaimTypes.Email, user.Email ?? string.Empty),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ?? string.Empty));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: configuration["Jwt:Issuer"] ?? string.Empty,
audience: configuration["Jwt:Issuer"] ?? string.Empty,
claims: claims,
expires: DateTime.Now.AddMinutes(30),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private string GenerateRefreshToken()
{
var randomNumber = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
private ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
/// <summary>
/// Get the principal from an expired token. This is used to validate the token and extract the user.
/// </summary>
/// <param name="token">The expired token as string.</param>
/// <returns>Claims principal.</returns>
/// <exception cref="SecurityTokenException">Thrown if provided token is invalid.</exception>
private static ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
{
var tokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ?? string.Empty)),
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtKey())),
ValidateLifetime = false,
};
@@ -210,17 +278,57 @@ public class AuthController(AliasDbContext context, UserManager<IdentityUser> us
return principal;
}
private string GenerateDeviceIdentifier(HttpRequest request)
/// <summary>
/// Generate a refresh token for a user. This token is used to request a new access token when the current
/// access token expires. The refresh token is long-lived by design.
/// </summary>
/// <returns>Random string to be used as refresh token.</returns>
private static string GenerateRefreshToken()
{
// TODO: Add more headers to the device identifier or let client send a unique identifier instead.
var userAgent = request.Headers.UserAgent.ToString();
var acceptLanguage = request.Headers.AcceptLanguage.ToString();
var randomNumber = new byte[32];
using var rng = RandomNumberGenerator.Create();
var rawIdentifier = $"{userAgent}|{acceptLanguage}";
return rawIdentifier;
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
private async Task<TokenModel> GenerateNewTokenForUser(IdentityUser user)
/// <summary>
/// Generate a Jwt access token for a user. This token 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.
/// </summary>
/// <param name="user">The user to generate the Jwt access token for.</param>
/// <returns>Access token as string.</returns>
private string GenerateJwtToken(AliasVaultUser user)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Name, user.UserName ?? string.Empty),
new(ClaimTypes.Email, user.Email ?? string.Empty),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtKey()));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: configuration["Jwt:Issuer"] ?? string.Empty,
audience: configuration["Jwt:Issuer"] ?? string.Empty,
claims: claims,
expires: timeProvider.UtcNow.AddMinutes(10),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>
/// Generates a new access and refresh token for a user and persists the refresh token
/// to the database.
/// </summary>
/// <param name="user">The user to generate the tokens for.</param>
/// <returns>TokenModel which includes new access and refresh token.</returns>
private async Task<TokenModel> GenerateNewTokensForUser(AliasVaultUser user)
{
var token = GenerateJwtToken(user);
var refreshToken = GenerateRefreshToken();
@@ -239,8 +347,8 @@ public class AuthController(AliasDbContext context, UserManager<IdentityUser> us
UserId = user.Id,
DeviceIdentifier = deviceIdentifier,
Value = refreshToken,
ExpireDate = DateTime.Now.AddDays(30),
CreatedAt = DateTime.Now,
ExpireDate = timeProvider.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow,
});
await context.SaveChangesAsync();

View File

@@ -4,9 +4,11 @@
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Controllers;
using System.Security.Claims;
using AliasServerDb;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -15,16 +17,16 @@ using Microsoft.AspNetCore.Mvc;
/// Base controller for requests that require authentication.
/// </summary>
/// <param name="userManager">UserManager instance.</param>
[Route("api/[controller]")]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[Authorize]
public class AuthenticatedRequestController(UserManager<IdentityUser> userManager) : ControllerBase
public class AuthenticatedRequestController(UserManager<AliasVaultUser> userManager) : ControllerBase
{
/// <summary>
/// Get the current authenticated user.
/// </summary>
/// <returns>IdentityUser object for current user.</returns>
protected async Task<IdentityUser?> GetCurrentUserAsync()
/// <returns>AliasVaultUser object for current user.</returns>
protected async Task<AliasVaultUser?> GetCurrentUserAsync()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new InvalidOperationException("Unable to find user ID.");
return await userManager.FindByIdAsync(userId);

View File

@@ -0,0 +1,43 @@
//-----------------------------------------------------------------------
// <copyright file="FaviconController.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Controllers;
using AliasServerDb;
using AliasVault.Shared.Models;
using Asp.Versioning;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Controller for retrieving favicons from external websites.
/// </summary>
/// <param name="userManager">UserManager instance.</param>
[ApiVersion("1")]
public class FaviconController(UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Proxies the request to the identity generator to generate a random identity.
/// </summary>
/// <param name="url">URL to extract the favicon from.</param>
/// <returns>Identity model.</returns>
[HttpGet("Extract")]
public async Task<IActionResult> Extract(string url)
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return Unauthorized();
}
// Get the favicon from the URL.
var image = await FaviconExtractor.FaviconExtractor.GetFaviconAsync(url);
// Return the favicon as base64 string of image representation.
return Ok(new FaviconExtractModel { Image = image });
}
}

View File

@@ -4,10 +4,12 @@
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Controllers;
using AliasGenerators.Identity;
using AliasGenerators.Identity.Implementations;
using AliasServerDb;
using Asp.Versioning;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -15,13 +17,14 @@ using Microsoft.AspNetCore.Mvc;
/// Controller for identity generation.
/// </summary>
/// <param name="userManager">UserManager instance.</param>
public class IdentityController(UserManager<IdentityUser> userManager) : AuthenticatedRequestController(userManager)
[ApiVersion("1")]
public class IdentityController(UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Proxies the request to the identity generator to generate a random identity.
/// </summary>
/// <returns>Identity model.</returns>
[HttpGet("generate")]
[HttpGet("Generate")]
public async Task<IActionResult> Generate()
{
var user = await GetCurrentUserAsync();
@@ -30,7 +33,7 @@ public class IdentityController(UserManager<IdentityUser> userManager) : Authent
return Unauthorized();
}
IIdentityGenerator identityGenerator = new FigIdentityGenerator();
var identityGenerator = new FigIdentityGenerator();
return Ok(await identityGenerator.GenerateRandomIdentityAsync());
}
}

View File

@@ -0,0 +1,53 @@
//-----------------------------------------------------------------------
// <copyright file="RootController.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Controllers;
using AliasServerDb;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Root controller that contains health check endpoints.
/// </summary>
[ApiController]
[Route("/")]
public class RootController : ControllerBase
{
/// <summary>
/// Root endpoint that returns a 200 OK if the database connection is successful
/// and the DB migrations are up-to-date.
/// </summary>
/// <returns>Http 200 if database connection is successful.</returns>
[HttpGet]
[ProducesResponseType<int>(StatusCodes.Status200OK)]
[ProducesResponseType<int>(StatusCodes.Status500InternalServerError)]
public IActionResult Get()
{
try
{
using (var dbContext = new AliasServerDbContext())
{
var appliedMigrations = dbContext.Database.GetAppliedMigrations();
var allMigrations = dbContext.Database.GetMigrations();
if (allMigrations.Except(appliedMigrations).Any())
{
// There are pending migrations
return StatusCode(500, "There are pending migrations. Please run 'dotnet ef database update' to apply them.");
}
// Database is up to date
return Ok("OK");
}
}
catch
{
return StatusCode(500, "Internal server error");
}
}
}

View File

@@ -0,0 +1,31 @@
//-----------------------------------------------------------------------
// <copyright file="TestController.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Controllers;
using AliasServerDb;
using Asp.Versioning;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Test controller that contains test endpoints called by pages on the client for E2E testing purposes.
/// </summary>
/// <param name="userManager">UserManager instance.</param>
[ApiVersion("1")]
public class TestController(UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Authenticated test request.
/// </summary>
/// <returns>List of aliases in JSON format.</returns>
[HttpGet("")]
public IActionResult TestCall()
{
return Ok();
}
}

View File

@@ -0,0 +1,115 @@
//-----------------------------------------------------------------------
// <copyright file="VaultController.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Controllers;
using AliasServerDb;
using AliasVault.Api.Vault;
using AliasVault.Api.Vault.RetentionRules;
using AliasVault.Shared.Providers.Time;
using Asp.Versioning;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Vault controller for handling CRUD operations on the database for encrypted vault entities.
/// </summary>
/// <param name="context">DbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
/// <param name="timeProvider">ITimeProvider instance.</param>
[ApiVersion("1")]
public class VaultController(AliasServerDbContext context, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Default retention policy for vaults.
/// </summary>
private readonly RetentionPolicy _retentionPolicy = new()
{
Rules = new List<IRetentionRule>
{
new DailyRetentionRule { DaysToKeep = 3 },
new WeeklyRetentionRule { WeeksToKeep = 1 },
new MonthlyRetentionRule { MonthsToKeep = 1 },
new VersionRetentionRule { VersionsToKeep = 3 },
},
};
/// <summary>
/// Get the newest version of the vault for the current user.
/// </summary>
/// <returns>List of aliases in JSON format.</returns>
[HttpGet("")]
public async Task<IActionResult> GetVault()
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return Unauthorized();
}
// Logic to retrieve vault for the user.
var vault = await context.Vaults
.Where(x => x.UserId == user.Id)
.OrderByDescending(x => x.UpdatedAt)
.FirstOrDefaultAsync();
// If no vault is found on server, return an empty object. This means the client will use an empty vault
// as starting point.
if (vault == null)
{
return Ok(new Shared.Models.WebApi.Vault(string.Empty, string.Empty, DateTime.MinValue, DateTime.MinValue));
}
return Ok(new Shared.Models.WebApi.Vault(vault.VaultBlob, vault.Version, vault.CreatedAt, vault.UpdatedAt));
}
/// <summary>
/// Save a new vault to the database for the current user.
/// </summary>
/// <param name="model">Vault model.</param>
/// <returns>IActionResult.</returns>
[HttpPost("")]
public async Task<IActionResult> Update([FromBody] Shared.Models.WebApi.Vault model)
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return Unauthorized();
}
// Create new vault entry.
var newVault = new AliasServerDb.Vault
{
UserId = user.Id,
VaultBlob = model.Blob,
Version = model.Version,
CreatedAt = timeProvider.UtcNow,
UpdatedAt = timeProvider.UtcNow,
};
// Run the vault retention manager to keep the required vaults according
// to the applied retention policies and delete the rest.
// We only select the Id and UpdatedAt fields to reduce the amount of data transferred from the database.
var existingVaults = await context.Vaults
.Where(x => x.UserId == user.Id)
.OrderByDescending(v => v.UpdatedAt)
.Select(x => new AliasServerDb.Vault { Id = x.Id, UpdatedAt = x.UpdatedAt })
.ToListAsync();
var vaultsToDelete = VaultRetentionManager.ApplyRetention(_retentionPolicy, existingVaults, timeProvider.UtcNow, newVault);
// Delete vaults that are not needed anymore.
context.Vaults.RemoveRange(vaultsToDelete);
// Add the new vault and commit to database.
await context.Vaults.AddAsync(newVault);
await context.SaveChangesAsync();
return Ok();
}
}

View File

@@ -9,7 +9,7 @@ WORKDIR /src
# Copy the project files and restore dependencies
COPY ["src/AliasVault.Api/AliasVault.Api.csproj", "src/AliasVault.Api/"]
COPY ["src/AliasDb/AliasDb.csproj", "src/AliasDb/"]
COPY ["src/Databases/AliasServerDb/AliasServerDb.csproj", "src/Databases/AliasServerDb/"]
COPY ["src/AliasVault.Shared/AliasVault.Shared.csproj", "src/AliasVault.Shared/"]
COPY ["src/AliasGenerators/AliasGenerators.csproj", "src/AliasGenerators/"]
RUN dotnet restore "src/AliasVault.Api/AliasVault.Api.csproj"
@@ -19,17 +19,12 @@ COPY . .
# Build the WebApi project
WORKDIR "/src/src/AliasVault.Api"
RUN dotnet build "AliasVault.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
RUN dotnet build "AliasVault.Api.csproj" -c "$BUILD_CONFIGURATION" -o /app/build
# Publish the application to the /app/publish directory in the container
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "AliasVault.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# Create the migration bundle
# Install the Entity Framework Core CLI tool and run migrations to create the database
RUN dotnet tool install --global dotnet-ef --version 8.0.5
RUN /root/.dotnet/tools/dotnet-ef migrations bundle -o /app/migrationbundle
RUN dotnet publish "AliasVault.Api.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app

View File

@@ -0,0 +1,40 @@
// -----------------------------------------------------------------------
// <copyright file="TimeValidationJwtBearerEvents.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
// -----------------------------------------------------------------------
namespace AliasVault.Api.Jwt;
using AliasVault.Shared.Providers.Time;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.JsonWebTokens;
/// <summary>
/// JwtBearerEvents implementation that validates the token expiration time based on
/// the current time provided by an ITimeProvider. This is used to be able to
/// test the token expiration logic in unit tests.
/// </summary>
public class TimeValidationJwtBearerEvents(ITimeProvider timeProvider) : JwtBearerEvents
{
/// <summary>
/// Validates the token expiration time based on the current time provided by the ITimeProvider.
/// </summary>
/// <param name="context">TokenValidatedContext.</param>
/// <returns>Async task.</returns>
public override Task TokenValidated(TokenValidatedContext context)
{
var jwtToken = context.SecurityToken as JsonWebToken;
if (jwtToken != null)
{
var now = timeProvider.UtcNow;
if (jwtToken.ValidTo < now)
{
context.Fail("Token has expired.");
}
}
return Task.CompletedTask;
}
}

View File

@@ -7,7 +7,10 @@
using System.Data.Common;
using System.Text;
using AliasDb;
using AliasServerDb;
using AliasVault.Api.Jwt;
using AliasVault.Shared.Providers.Time;
using Asp.Versioning;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
@@ -18,6 +21,9 @@ using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
builder.Services.AddSingleton<ITimeProvider, SystemTimeProvider>();
builder.Services.AddScoped<TimeValidationJwtBearerEvents>();
builder.Services.AddLogging(logging =>
{
logging.AddConsole();
@@ -26,7 +32,6 @@ builder.Services.AddLogging(logging =>
logging.AddFilter("Microsoft.AspNetCore.Identity.UserManager", LogLevel.Error);
});
// Add services to the container.
builder.Services.AddSingleton<DbConnection>(container =>
{
var configFile = new ConfigurationBuilder()
@@ -34,13 +39,13 @@ builder.Services.AddSingleton<DbConnection>(container =>
.AddJsonFile("appsettings.json")
.Build();
var connection = new SqliteConnection(configFile.GetConnectionString("AliasDbContext"));
var connection = new SqliteConnection(configFile.GetConnectionString("AliasServerDbContext"));
connection.Open();
return connection;
});
builder.Services.AddDbContext<AliasDbContext>((container, options) =>
builder.Services.AddDbContext<AliasServerDbContext>((container, options) =>
{
var connection = container.GetRequiredService<DbConnection>();
options.UseSqlite(connection).UseLazyLoadingProxies();
@@ -52,7 +57,7 @@ builder.Services.Configure<DataProtectionTokenProviderOptions>(options =>
options.TokenLifespan = TimeSpan.FromDays(30);
options.Name = "AliasVault";
});
builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
builder.Services.AddIdentity<AliasVaultUser, IdentityRole>(options =>
{
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
@@ -61,11 +66,11 @@ builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
options.Password.RequiredLength = 8;
options.Password.RequiredUniqueChars = 0;
options.SignIn.RequireConfirmedAccount = false;
options.Tokens.ProviderMap.Add("AliasVault", new TokenProviderDescriptor(typeof(DataProtectorTokenProvider<IdentityUser>)));
options.Tokens.ProviderMap.Add("AliasVault", new TokenProviderDescriptor(typeof(DataProtectorTokenProvider<AliasVaultUser>)));
})
.AddEntityFrameworkStores<AliasDbContext>()
.AddEntityFrameworkStores<AliasServerDbContext>()
.AddDefaultTokenProviders()
.AddTokenProvider<DataProtectorTokenProvider<IdentityUser>>("AliasVault");
.AddTokenProvider<DataProtectorTokenProvider<AliasVaultUser>>("AliasVault");
builder.Services.AddAuthentication(options =>
{
@@ -73,6 +78,12 @@ builder.Services.AddAuthentication(options =>
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY");
if (jwtKey is null)
{
throw new KeyNotFoundException("JWT_KEY environment variable is not set.");
}
options.IncludeErrorDetails = true;
options.TokenValidationParameters = new TokenValidationParameters
{
@@ -83,9 +94,13 @@ builder.Services.AddAuthentication(options =>
ValidateIssuerSigningKey = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Issuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ?? string.Empty)),
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ClockSkew = TimeSpan.Zero,
};
// Add custom event handler for validating token expiration time in order
// to be able to mutate current time for testing the token expiration logic in unit tests.
options.EventsType = typeof(TimeValidationJwtBearerEvents);
});
// Configure CORS
@@ -99,7 +114,21 @@ builder.Services.AddCors(options =>
});
builder.Services.AddControllers();
builder.Services.AddMemoryCache();
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "AliasVault API", Version = "v1" });
@@ -150,9 +179,9 @@ app.MapControllers();
using (var scope = app.Services.CreateScope())
{
var container = scope.ServiceProvider;
var db = container.GetRequiredService<AliasDbContext>();
var db = container.GetRequiredService<AliasServerDbContext>();
await db.Database.EnsureCreatedAsync();
await db.Database.MigrateAsync();
}
await app.RunAsync();

View File

@@ -1,33 +1,26 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:39952",
"sslPort": 44368
}
},
{
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5092",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
"ASPNETCORE_ENVIRONMENT": "Development",
"JWT_KEY": "12345678901234567890123456789012"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5092"
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7223;http://localhost:5092",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
"ASPNETCORE_ENVIRONMENT": "Development",
"JWT_KEY": "12345678901234567890123456789012"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7223;http://localhost:5092"
},
"IIS Express": {
"commandName": "IISExpress",
@@ -37,5 +30,14 @@
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:39952",
"sslPort": 44368
}
}
}

View File

@@ -0,0 +1,21 @@
//-----------------------------------------------------------------------
// <copyright file="RetentionPolicy.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Vault;
using AliasVault.Api.Vault.RetentionRules;
/// <summary>
/// The retention policy that contains one or more retention rules.
/// </summary>
public class RetentionPolicy
{
/// <summary>
/// Gets or sets the rules that this policy consists of.
/// </summary>
public List<IRetentionRule> Rules { get; set; } = new();
}

View File

@@ -0,0 +1,32 @@
//-----------------------------------------------------------------------
// <copyright file="DailyRetentionRule.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Vault.RetentionRules;
using AliasServerDb;
/// <summary>
/// Daily retention rule that keeps the latest vault for each day.
/// </summary>
public class DailyRetentionRule : IRetentionRule
{
/// <summary>
/// Gets or sets amount of days to keep vault.
/// </summary>
public int DaysToKeep { get; set; }
/// <inheritdoc cref="IRetentionRule.ApplyRule"/>
public IEnumerable<Vault> ApplyRule(List<Vault> vaults, DateTime now)
{
// For the specified amount of days, take last vault per day.
return vaults
.GroupBy(x => x.UpdatedAt.Date)
.Select(g => g.OrderByDescending(x => x.UpdatedAt).First())
.OrderByDescending(x => x.UpdatedAt)
.Take(DaysToKeep);
}
}

View File

@@ -0,0 +1,24 @@
//-----------------------------------------------------------------------
// <copyright file="IRetentionRule.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Vault.RetentionRules;
using AliasServerDb;
/// <summary>
/// Retention rule interface that specify the contract for all retention rules.
/// </summary>
public interface IRetentionRule
{
/// <summary>
/// Apply retention rule.
/// </summary>
/// <param name="vaults">List of existing vaults to apply the retention rule to.</param>
/// <param name="now">Current DateTime.</param>
/// <returns>Vaults that should be kept according to the retention rule.</returns>
IEnumerable<Vault> ApplyRule(List<Vault> vaults, DateTime now);
}

View File

@@ -0,0 +1,31 @@
//-----------------------------------------------------------------------
// <copyright file="MonthlyRetentionRule.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Vault.RetentionRules;
using AliasServerDb;
/// <summary>
/// Monthly retention rule that keeps the latest vault for each month.
/// </summary>
public class MonthlyRetentionRule : IRetentionRule
{
/// <summary>
/// Gets or sets amount of months to keep vault.
/// </summary>
public int MonthsToKeep { get; set; }
/// <inheritdoc cref="IRetentionRule.ApplyRule"/>
public IEnumerable<Vault> ApplyRule(List<Vault> vaults, DateTime now)
{
return vaults
.GroupBy(x => x.UpdatedAt.Month)
.Select(g => g.OrderByDescending(x => x.UpdatedAt).First())
.OrderByDescending(x => x.UpdatedAt)
.Take(MonthsToKeep);
}
}

View File

@@ -0,0 +1,32 @@
//-----------------------------------------------------------------------
// <copyright file="VersionRetentionRule.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Vault.RetentionRules;
using AliasServerDb;
/// <summary>
/// Version retention rule that keeps the latest X unique versions of the vault.
/// </summary>
public class VersionRetentionRule : IRetentionRule
{
/// <summary>
/// Gets or sets amount of versions to keep the vault.
/// </summary>
public int VersionsToKeep { get; set; }
/// <inheritdoc cref="IRetentionRule.ApplyRule"/>
public IEnumerable<Vault> ApplyRule(List<Vault> vaults, DateTime now)
{
// For the specified amount of versions, take last vault per version.
return vaults
.GroupBy(x => x.Version)
.Select(g => g.OrderByDescending(x => x.UpdatedAt).First())
.OrderByDescending(x => x.UpdatedAt)
.Take(VersionsToKeep);
}
}

View File

@@ -0,0 +1,38 @@
//-----------------------------------------------------------------------
// <copyright file="WeeklyRetentionRule.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Vault.RetentionRules;
using AliasServerDb;
/// <summary>
/// Weekly retention rule that keeps the latest vault for each week.
/// </summary>
public class WeeklyRetentionRule : IRetentionRule
{
/// <summary>
/// Gets or sets amount of weeks to keep vault.
/// </summary>
public int WeeksToKeep { get; set; }
/// <inheritdoc cref="IRetentionRule.ApplyRule"/>
public IEnumerable<Vault> ApplyRule(List<Vault> vaults, DateTime now)
{
// Helper function to get the start of the week with Monday as the first day of the week.
DateTime GetStartOfWeek(DateTime date)
{
int diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
return date.Date.AddDays(-1 * diff).Date;
}
return vaults
.GroupBy(x => GetStartOfWeek(x.UpdatedAt))
.Select(g => g.OrderByDescending(x => x.UpdatedAt).First())
.OrderByDescending(x => x.UpdatedAt)
.Take(WeeksToKeep);
}
}

View File

@@ -0,0 +1,65 @@
//-----------------------------------------------------------------------
// <copyright file="VaultRetentionManager.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Api.Vault;
using System;
using System.Collections.Generic;
using System.Linq;
using AliasServerDb;
/// <summary>
/// History manager for vaults that keeps track of vault history and applies retention rules to
/// determine how many vaults to keep as backups and automatically deletes vaults that do no
/// match the applied retention policies.
/// </summary>
public static class VaultRetentionManager
{
/// <summary>
/// Applies retention policies to a list of existing vaults and a new vault.
/// </summary>
/// <param name="retentionPolicy">List of retention policies to apply.</param>
/// <param name="existingVaults">List of existing vaults for a certain user.</param>
/// <param name="now">DateTime which represents current time.</param>
/// <param name="newVault">New encrypted vault to be added that is also taken into account for calculating retention policy.</param>
/// <returns>List of vaults to delete according to the retention policies.</returns>
public static List<Vault> ApplyRetention(RetentionPolicy retentionPolicy, List<Vault> existingVaults, DateTime now, Vault? newVault = null)
{
// Add the new vault to the list of existing vaults if provided
if (newVault is not null)
{
existingVaults = new List<Vault>(existingVaults) { newVault };
}
// Sort vaults by UpdatedAt in descending order
existingVaults = existingVaults.OrderByDescending(v => v.UpdatedAt).ToList();
var vaultsToKeep = new HashSet<Vault>();
// Process retention rules
foreach (var rule in retentionPolicy.Rules)
{
var keptVaults = rule.ApplyRule(existingVaults, now);
foreach (var vault in keptVaults)
{
vaultsToKeep.Add(vault);
}
}
// Always keep the most recent vault
if (existingVaults.Count > 0 && !vaultsToKeep.Contains(existingVaults[0]))
{
vaultsToKeep.Add(existingVaults[0]);
}
// Determine vaults to delete
var vaultsToDelete = existingVaults.Except(vaultsToKeep).ToList();
// Return the vaults to delete
return vaultsToDelete;
}
}

View File

@@ -6,11 +6,10 @@
}
},
"Jwt": {
"Key": "[[&lokl$4r<ak{f}4d#iv7>92i*)=sfo",
"Issuer": "YourIssuer"
"Issuer": "AliasVault"
},
"ConnectionStrings": {
"AliasDbContext": "Data Source=../../database/aliasdb.sqlite"
"AliasServerDbContext": "Data Source=../../database/AliasServerDb.sqlite"
},
"AllowedHosts": "*"
}

View File

@@ -1,9 +1,5 @@
#!/bin/sh
# Apply database migrations using the bundle
echo "Running database migrations..."
/app/migrationbundle
# Start the application
echo "Starting application..."
dotnet /app/AliasVault.Api.dll

View File

@@ -0,0 +1,19 @@
//-----------------------------------------------------------------------
// <copyright file="FaviconExtractModel.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models;
/// <summary>
/// FaviconExtractModel model.
/// </summary>
public class FaviconExtractModel
{
/// <summary>
/// Gets or sets favicon image as byte array.
/// </summary>
public byte[]? Image { get; set; } = null!;
}

View File

@@ -7,6 +7,8 @@
namespace AliasVault.Shared.Models;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Login model.
/// </summary>
@@ -15,10 +17,13 @@ public class LoginModel
/// <summary>
/// Gets or sets the email.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; } = null!;
/// <summary>
/// Gets or sets the password.
/// </summary>
[Required]
public string Password { get; set; } = null!;
}

View File

@@ -7,6 +7,9 @@
namespace AliasVault.Shared.Models;
using System.ComponentModel.DataAnnotations;
using AliasVault.Shared.Models.Validation;
/// <summary>
/// Register model.
/// </summary>
@@ -15,20 +18,27 @@ public class RegisterModel
/// <summary>
/// Gets or sets the email.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; } = null!;
/// <summary>
/// Gets or sets the password.
/// </summary>
[Required]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters long.")]
public string Password { get; set; } = null!;
/// <summary>
/// Gets or sets the password confirmation.
/// </summary>
[Required]
[Compare("Password", ErrorMessage = "Passwords do not match.")]
public string PasswordConfirm { get; set; } = null!;
/// <summary>
/// Gets or sets a value indicating whether the terms and conditions are accepted or not.
/// </summary>
[MustBeTrue(ErrorMessage = "You must accept the terms and conditions.")]
public bool AcceptTerms { get; set; } = false;
}

View File

@@ -0,0 +1,22 @@
//-----------------------------------------------------------------------
// <copyright file="UnlockModel.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Unlock model.
/// </summary>
public class UnlockModel
{
/// <summary>
/// Gets or sets the password.
/// </summary>
[Required]
public string Password { get; set; } = null!;
}

View File

@@ -0,0 +1,30 @@
//-----------------------------------------------------------------------
// <copyright file="MustBeTrueAttribute.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.Validation;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Validation attribute to ensure that a boolean property is true.
/// </summary>
public class MustBeTrueAttribute : ValidationAttribute
{
/// <inheritdoc />
public override bool IsValid(object? value)
{
switch (value)
{
case null:
return false;
case bool b:
return b;
default:
throw new InvalidOperationException("Can only be used on boolean properties.");
}
}
}

View File

@@ -1,39 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="Alias.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi;
/// <summary>
/// Alias model.
/// </summary>
public class Alias
{
/// <summary>
/// Gets or sets the Alias Service object.
/// </summary>
public Service Service { get; set; } = null!;
/// <summary>
/// Gets or sets the Alias Identity object.
/// </summary>
public Identity Identity { get; set; } = null!;
/// <summary>
/// Gets or sets the Alias Password object.
/// </summary>
public Password Password { get; set; } = null!;
/// <summary>
/// Gets or sets the Alias CreateDate.
/// </summary>
public DateTime CreateDate { get; set; }
/// <summary>
/// Gets or sets the Alias LastUpdate.
/// </summary>
public DateTime LastUpdate { get; set; }
}

View File

@@ -0,0 +1,42 @@
//-----------------------------------------------------------------------
// <copyright file="AuthFinishModel.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.Auth;
/// <summary>
/// Auth finish model using SRP (Secure Remote Password) protocol.
/// </summary>
public class AuthFinishModel
{
/// <summary>
/// Initializes a new instance of the <see cref="AuthFinishModel"/> class.
/// </summary>
/// <param name="email">Email.</param>
/// <param name="a">A.</param>
/// <param name="m1">M1.</param>
public AuthFinishModel(string email, string a, string m1)
{
Email = email;
A = a;
M1 = m1;
}
/// <summary>
/// Gets or sets the email.
/// </summary>
public string Email { get; set; }
/// <summary>
/// Gets or sets A which is a value that is used to verify the user's identity.
/// </summary>
public string A { get; set; }
/// <summary>
/// Gets or sets M1 which is a value that is used to verify the user's identity.
/// </summary>
public string M1 { get; set; }
}

View File

@@ -0,0 +1,28 @@
//-----------------------------------------------------------------------
// <copyright file="AuthStartModel.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.Auth;
/// <summary>
/// Auth start model using SRP (Secure Remote Password) protocol.
/// </summary>
public class AuthStartModel
{
/// <summary>
/// Initializes a new instance of the <see cref="AuthStartModel"/> class.
/// </summary>
/// <param name="email">Email.</param>
public AuthStartModel(string email)
{
this.Email = email;
}
/// <summary>
/// Gets or sets the email.
/// </summary>
public string Email { get; set; }
}

View File

@@ -0,0 +1,28 @@
//-----------------------------------------------------------------------
// <copyright file="LoginRequest.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.Auth;
/// <summary>
/// Represents a login request.
/// </summary>
public class LoginRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="LoginRequest"/> class.
/// </summary>
/// <param name="email">Email.</param>
public LoginRequest(string email)
{
Email = email;
}
/// <summary>
/// Gets or sets the email address.
/// </summary>
public string Email { get; set; }
}

View File

@@ -0,0 +1,39 @@
//-----------------------------------------------------------------------
// <copyright file="LoginResponse.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.Auth;
using System.Text.Json.Serialization;
/// <summary>
/// Represents a login response.
/// </summary>
public class LoginResponse
{
/// <summary>
/// Initializes a new instance of the <see cref="LoginResponse"/> class.
/// </summary>
/// <param name="salt">Salt.</param>
/// <param name="serverEphemeral">Server ephemeral.</param>
public LoginResponse(string salt, string serverEphemeral)
{
Salt = salt;
ServerEphemeral = serverEphemeral;
}
/// <summary>
/// Gets or sets the salt.
/// </summary>
[JsonPropertyName("salt")]
public string Salt { get; set; }
/// <summary>
/// Gets or sets the server's public ephemeral value.
/// </summary>
[JsonPropertyName("serverEphemeral")]
public string ServerEphemeral { get; set; }
}

View File

@@ -0,0 +1,43 @@
//-----------------------------------------------------------------------
// <copyright file="RegisterModel.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.Auth;
/// <summary>
/// This class represents the model for registering a new user
/// using SRP (Secure Remote Password) protocol.
/// </summary>
public class RegisterModel
{
/// <summary>
/// Initializes a new instance of the <see cref="RegisterModel"/> class.
/// </summary>
/// <param name="email">Email.</param>
/// <param name="salt">Salt.</param>
/// <param name="verifier">Verifier.</param>
public RegisterModel(string email, string salt, string verifier)
{
Email = email;
Salt = salt;
Verifier = verifier;
}
/// <summary>
/// Gets or sets the email.
/// </summary>
public string Email { get; set; }
/// <summary>
/// Gets or sets the salt.
/// </summary>
public string Salt { get; set; }
/// <summary>
/// Gets or sets the verifier.
/// </summary>
public string Verifier { get; set; }
}

View File

@@ -0,0 +1,43 @@
//-----------------------------------------------------------------------
// <copyright file="ValidateLoginRequest.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.Auth
{
/// <summary>
/// Represents a request to validate a login.
/// </summary>
public class ValidateLoginRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="ValidateLoginRequest"/> class.
/// </summary>
/// <param name="email">Email.</param>
/// <param name="clientPublicEphemeral">Client public ephemeral.</param>
/// <param name="clientSessionProof">Client session proof.</param>
public ValidateLoginRequest(string email, string clientPublicEphemeral, string clientSessionProof)
{
Email = email;
ClientPublicEphemeral = clientPublicEphemeral;
ClientSessionProof = clientSessionProof;
}
/// <summary>
/// Gets or sets the email.
/// </summary>
public string Email { get; set; }
/// <summary>
/// Gets or sets the client's public ephemeral value.
/// </summary>
public string ClientPublicEphemeral { get; set; }
/// <summary>
/// Gets or sets the client's session proof.
/// </summary>
public string ClientSessionProof { get; set; }
}
}

View File

@@ -0,0 +1,39 @@
//-----------------------------------------------------------------------
// <copyright file="ValidateLoginResponse.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.Auth;
using System.Text.Json.Serialization;
/// <summary>
/// Represents a request to validate a login.
/// </summary>
public class ValidateLoginResponse
{
/// <summary>
/// Initializes a new instance of the <see cref="ValidateLoginResponse"/> class.
/// </summary>
/// <param name="serverSessionProof">Client session proof.</param>
/// <param name="token">Token model.</param>
public ValidateLoginResponse(string serverSessionProof, TokenModel token)
{
ServerSessionProof = serverSessionProof;
Token = token;
}
/// <summary>
/// Gets or sets the server's session proof.
/// </summary>
[JsonPropertyName("serverSessionProof")]
public string ServerSessionProof { get; set; }
/// <summary>
/// Gets or sets the JWT and refresh token.
/// </summary>
[JsonPropertyName("token")]
public TokenModel Token { get; set; }
}

View File

@@ -1,104 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="Identity.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi;
/// <summary>
/// Identity model.
/// </summary>
public class Identity
{
/// <summary>
/// Gets or sets the identity id.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the gender.
/// </summary>
public string? Gender { get; set; }
/// <summary>
/// Gets or sets the first name.
/// </summary>
public string? FirstName { get; set; }
/// <summary>
/// Gets or sets the last name.
/// </summary>
public string? LastName { get; set; }
/// <summary>
/// Gets or sets the nickname.
/// </summary>
public string? NickName { get; set; }
/// <summary>
/// Gets or sets the birth date.
/// </summary>
public string? BirthDate { get; set; }
/// <summary>
/// Gets or sets the street address.
/// </summary>
public string? AddressStreet { get; set; }
/// <summary>
/// Gets or sets the city.
/// </summary>
public string? AddressCity { get; set; }
/// <summary>
/// Gets or sets the state.
/// </summary>
public string? AddressState { get; set; }
/// <summary>
/// Gets or sets the zip code.
/// </summary>
public string? AddressZipCode { get; set; }
/// <summary>
/// Gets or sets the country.
/// </summary>
public string? AddressCountry { get; set; }
/// <summary>
/// Gets or sets the hobbies.
/// </summary>
public string? Hobbies { get; set; }
/// <summary>
/// Gets or sets the email prefix.
/// </summary>
public string? EmailPrefix { get; set; }
/// <summary>
/// Gets or sets the mobile phone number.
/// </summary>
public string? PhoneMobile { get; set; }
/// <summary>
/// Gets or sets the bank account IBAN.
/// </summary>
public string? BankAccountIBAN { get; set; }
/// <summary>
/// Gets or sets the date and time of creation.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the date and time of last update.
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// Gets or sets the default password.
/// </summary>
public Password? DefaultPassword { get; set; }
}

View File

@@ -1,34 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="Password.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi;
/// <summary>
/// Password model.
/// </summary>
public class Password
{
/// <summary>
/// Gets or sets the value of the password.
/// </summary>
public string Value { get; set; } = null!;
/// <summary>
/// Gets or sets the description of the password.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the date and time when the password was created.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the date and time when the password was last updated.
/// </summary>
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,93 @@
//-----------------------------------------------------------------------
// <copyright file="ServerValidationErrorResponse.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi;
using System.Text.Json.Serialization;
/// <summary>
/// Represents the structure of a validation error response from the API.
/// </summary>
public class ServerValidationErrorResponse
{
/// <summary>
/// Gets or sets the type of the error.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = null!;
/// <summary>
/// Gets or sets the title of the error.
/// </summary>
[JsonPropertyName("title")]
public string Title { get; set; } = null!;
/// <summary>
/// Gets or sets the HTTP status code of the response.
/// </summary>
[JsonPropertyName("status")]
public int Status { get; set; }
/// <summary>
/// Gets or sets the validation errors. The key is the name of the field that has the error, and the value is an array of error messages for that field.
/// </summary>
[JsonPropertyName("errors")]
public Dictionary<string, string[]> Errors { get; set; } = new();
/// <summary>
/// Gets or sets the trace ID of the error.
/// </summary>
[JsonPropertyName("traceId")]
public string TraceId { get; set; } = null!;
/// <summary>
/// Creates a new instance of <see cref="ServerValidationErrorResponse"/>.
/// </summary>
/// <param name="title">Title of the error.</param>
/// <param name="status">Status code.</param>
/// <returns>ServerValidationErrorResponse object.</returns>
public static ServerValidationErrorResponse Create(string title, int status)
{
var errors = new Dictionary<string, string[]>
{
{ title, [title] },
};
return new ServerValidationErrorResponse
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
Title = title,
Errors = errors,
Status = status,
TraceId = Guid.NewGuid().ToString(),
};
}
/// <summary>
/// Creates a new instance of <see cref="ServerValidationErrorResponse"/>.
/// </summary>
/// <param name="errorArray">Array with errors.</param>
/// <param name="status">Status code.</param>
/// <returns>ServerValidationErrorResponse object.</returns>
public static ServerValidationErrorResponse Create(string[] errorArray, int status)
{
var errors = new Dictionary<string, string[]>();
foreach (var t in errorArray)
{
errors.Add(t, new[] { t });
}
return new ServerValidationErrorResponse
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
Title = errorArray[0],
Errors = errors,
Status = status,
TraceId = Guid.NewGuid().ToString(),
};
}
}

View File

@@ -1,44 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="Service.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi;
/// <summary>
/// Service model.
/// </summary>
public class Service
{
/// <summary>
/// Gets or sets the name of the service.
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// Gets or sets the description of the service.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the URL of the service.
/// </summary>
public string? Url { get; set; }
/// <summary>
/// Gets or sets the logo URL of the service.
/// </summary>
public string? LogoUrl { get; set; }
/// <summary>
/// Gets or sets the creation date and time of the service.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the last updated date and time of the service.
/// </summary>
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,49 @@
//-----------------------------------------------------------------------
// <copyright file="Vault.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi;
/// <summary>
/// Vault model.
/// </summary>
public class Vault
{
/// <summary>
/// Initializes a new instance of the <see cref="Vault"/> class.
/// </summary>
/// <param name="blob">Blob.</param>
/// <param name="version">Version of the vault data model (migration).</param>
/// <param name="createdAt">CreatedAt.</param>
/// <param name="updatedAt">UpdatedAt.</param>
public Vault(string blob, string version, DateTime createdAt, DateTime updatedAt)
{
Blob = blob;
Version = version;
CreatedAt = createdAt;
UpdatedAt = updatedAt;
}
/// <summary>
/// Gets or sets the vault blob.
/// </summary>
public string Blob { get; set; }
/// <summary>
/// Gets or sets the vault version.
/// </summary>
public string Version { get; set; }
/// <summary>
/// Gets or sets the date and time of creation.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the date and time of last update.
/// </summary>
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,19 @@
// -----------------------------------------------------------------------
// <copyright file="ITimeProvider.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
// -----------------------------------------------------------------------
namespace AliasVault.Shared.Providers.Time;
/// <summary>
/// Time provider interface for getting the current time. We use this to be able to mock the time in tests.
/// </summary>
public interface ITimeProvider
{
/// <summary>
/// Gets current time in UTC.
/// </summary>
DateTime UtcNow { get; }
}

View File

@@ -0,0 +1,19 @@
// -----------------------------------------------------------------------
// <copyright file="SystemTimeProvider.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
// -----------------------------------------------------------------------
namespace AliasVault.Shared.Providers.Time;
/// <summary>
/// Default time provider that uses the system clock.
/// </summary>
public class SystemTimeProvider : ITimeProvider
{
/// <summary>
/// Gets current time in UTC.
/// </summary>
public DateTime UtcNow => DateTime.UtcNow;
}

View File

@@ -0,0 +1,39 @@
// -----------------------------------------------------------------------
// <copyright file="TestTimeProvider.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
// -----------------------------------------------------------------------
namespace AliasVault.Shared.Providers.Time;
/// <summary>
/// Test time provider that allows mutating the current time for testing purposes.
/// </summary>
public class TestTimeProvider : ITimeProvider
{
private DateTime _currentTime = DateTime.UtcNow;
/// <summary>
/// Gets current time in UTC.
/// </summary>
public DateTime UtcNow => _currentTime;
/// <summary>
/// Set the current time to a specific date and time.
/// </summary>
/// <param name="dateTime">DateTime to set current time to.</param>
public void SetUtcNow(DateTime dateTime)
{
_currentTime = dateTime;
}
/// <summary>
/// Advance current time by a specific time span.
/// </summary>
/// <param name="timeSpan">Amount of time to advance current time by.</param>
public void AdvanceBy(TimeSpan timeSpan)
{
_currentTime = _currentTime.Add(timeSpan);
}
}

View File

@@ -1,24 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<RootNamespace>AliasVault.WebApp</RootNamespace>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<BuildVersion>$([System.DateTime]::UtcNow.ToString("yyyy-MM-dd HH:mm:ss"))</BuildVersion>
<WasmBuildNative>true</WasmBuildNative>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DocumentationFile>bin\Debug\net8.0\AliasVault.WebApp.xml</DocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<DocumentationFile>bin\Debug\net8.0\AliasVault.WebApp.xml</DocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CacheBuster>dev</CacheBuster>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>true</DebugSymbols>
<DocumentationFile>bin\Release\net8.0\AliasVault.WebApp.xml</DocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Optimize>True</Optimize>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<DebugSymbols>true</DebugSymbols>
<DocumentationFile>bin\Release\net8.0\AliasVault.WebApp.xml</DocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Optimize>True</Optimize>
<CacheBuster>$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss"))</CacheBuster>
</PropertyGroup>
<UsingTask TaskName="ReplaceText" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<InputFile ParameterType="System.String" Required="true" />
<OutputFile ParameterType="System.String" Required="true" />
<CacheBuster ParameterType="System.String" Required="true" />
<BuildVersion ParameterType="System.String" Required="true" />
</ParameterGroup>
<Task>
<Code Type="Fragment" Language="cs">
<![CDATA[
string content = File.ReadAllText(InputFile);
content = content.Replace("@CacheBuster", CacheBuster).Replace("@BuildVersion", BuildVersion);
File.WriteAllText(OutputFile, content);
Log.LogMessage(MessageImportance.High, "Replaced content in " + OutputFile);
]]>
</Code>
</Task>
</UsingTask>
<Target Name="GenerateCacheBustedIndexHtml" BeforeTargets="Build">
<ReplaceText InputFile="wwwroot/index.template.html" OutputFile="wwwroot/index.html" CacheBuster="$(CacheBuster)" BuildVersion="$(BuildVersion)" />
</Target>
<ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.6" />
@@ -36,6 +63,9 @@
<Content Update="wwwroot\appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\appsettings.Development.json" Condition="Exists('wwwroot\appsettings.Development.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
@@ -43,19 +73,11 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AliasDb\AliasDb.csproj" />
<ProjectReference Include="..\Databases\AliasClientDb\AliasClientDb.csproj" />
<ProjectReference Include="..\AliasGenerators\AliasGenerators.csproj" />
<ProjectReference Include="..\AliasVault.Shared\AliasVault.Shared.csproj" />
<ProjectReference Include="..\Utilities\Cryptography\Cryptography.csproj" />
<ProjectReference Include="..\Utilities\CsvImportExport\CsvImportExport.csproj" />
<ProjectReference Include="..\Utilities\FaviconExtractor\FaviconExtractor.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="nginx.conf">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Folder Include="Pages\Aliases\Mailbox\Models\" />
</ItemGroup>
</Project>

View File

@@ -3,8 +3,7 @@
<div class="flex flex-col items-center justify-center px-6 pt-8 mx-auto md:h-screen pt:mt-0 dark:bg-gray-900">
<Logo />
<!-- Card -->
<div class="w-full max-w-xl p-6 space-y-8 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="w-full max-w-xl p-6 space-y-4 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800">
@Body
</div>
</div>

View File

@@ -0,0 +1,180 @@
//-----------------------------------------------------------------------
// <copyright file="LoginBase.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.WebApp.Auth.Pages.Base;
using System.Net.Http.Json;
using System.Text.Json;
using AliasVault.Shared.Models.WebApi;
using AliasVault.Shared.Models.WebApi.Auth;
using AliasVault.WebApp.Services.Auth;
using Blazored.LocalStorage;
using Cryptography;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.JSInterop;
/// <summary>
/// Base authorize page that all pages that are part of the logged in website should inherit from.
/// All pages that inherit from this class will require the user to be logged in and have a confirmed email.
/// Also, a default set of breadcrumbs is added in the parent OnInitialized method.
/// </summary>
public class LoginBase : OwningComponentBase
{
/// <summary>
/// Gets or sets the NavigationManager.
/// </summary>
[Inject]
public NavigationManager NavigationManager { get; set; } = null!;
/// <summary>
/// Gets or sets the HttpClient.
/// </summary>
[Inject]
public HttpClient Http { get; set; } = null!;
/// <summary>
/// Gets or sets the AuthenticationStateProvider.
/// </summary>
[Inject]
public AuthenticationStateProvider AuthStateProvider { get; set; } = null!;
/// <summary>
/// Gets or sets the GlobalNotificationService.
/// </summary>
[Inject]
public GlobalNotificationService GlobalNotificationService { get; set; } = null!;
/// <summary>
/// Gets or sets the IJSRuntime.
/// </summary>
[Inject]
public IJSRuntime Js { get; set; } = null!;
/// <summary>
/// Gets or sets the DbService.
/// </summary>
[Inject]
public DbService DbService { get; set; } = null!;
/// <summary>
/// Gets or sets the AuthService.
/// </summary>
[Inject]
public AuthService AuthService { get; set; } = null!;
/// <summary>
/// Gets or sets the LocalStorage.
/// </summary>
[Inject]
public ILocalStorageService LocalStorage { get; set; } = null!;
/// <summary>
/// Parses the response content and displays the server validation errors.
/// </summary>
/// <param name="responseContent">Response content.</param>
/// <returns>List of errors if something went wrong.</returns>
public static List<string> ParseResponse(string responseContent)
{
var returnErrors = new List<string>();
var errorResponse = System.Text.Json.JsonSerializer.Deserialize<ServerValidationErrorResponse>(responseContent);
if (errorResponse is not null)
{
foreach (var error in errorResponse.Errors)
{
returnErrors.AddRange(error.Value);
}
}
return returnErrors;
}
/// <summary>
/// Gets the username from the authentication state asynchronously.
/// </summary>
/// <param name="email">Email address.</param>
/// <param name="password">Password.</param>
/// <returns>List of errors if something went wrong.</returns>
protected async Task<List<string>> ProcessLoginAsync(string email, string password)
{
// Send request to server with email to get server ephemeral public key.
var result = await Http.PostAsJsonAsync("api/v1/Auth/login", new LoginRequest(email));
var responseContent = await result.Content.ReadAsStringAsync();
if (!result.IsSuccessStatusCode)
{
return ParseResponse(responseContent);
}
var loginResponse = JsonSerializer.Deserialize<LoginResponse>(responseContent);
if (loginResponse == null)
{
return
[
"An error occurred while processing the login request.",
];
}
// 3. Client derives shared session key.
byte[] passwordHash = await Encryption.DeriveKeyFromPasswordAsync(password, loginResponse.Salt);
var passwordHashString = BitConverter.ToString(passwordHash).Replace("-", string.Empty);
var clientEphemeral = Srp.GenerateEphemeralClient();
var privateKey = Srp.DerivePrivateKey(loginResponse.Salt, email, passwordHashString);
var clientSession = Srp.DeriveSessionClient(
privateKey,
clientEphemeral.Secret,
loginResponse.ServerEphemeral,
loginResponse.Salt,
email);
// 4. Client sends proof of session key to server.
result = await Http.PostAsJsonAsync("api/v1/Auth/validate", new ValidateLoginRequest(email, clientEphemeral.Public, clientSession.Proof));
responseContent = await result.Content.ReadAsStringAsync();
if (!result.IsSuccessStatusCode)
{
return ParseResponse(responseContent);
}
var validateLoginResponse = JsonSerializer.Deserialize<ValidateLoginResponse>(responseContent);
if (validateLoginResponse == null)
{
return
[
"An error occurred while processing the login request.",
];
}
// 5. Client verifies proof.
Srp.VerifySession(clientEphemeral.Public, clientSession, validateLoginResponse.ServerSessionProof);
// Store the tokens in local storage.
await AuthService.StoreAccessTokenAsync(validateLoginResponse.Token.Token);
await AuthService.StoreRefreshTokenAsync(validateLoginResponse.Token.RefreshToken);
// Store the encryption key in memory.
AuthService.StoreEncryptionKey(passwordHash);
await AuthStateProvider.GetAuthenticationStateAsync();
GlobalNotificationService.ClearMessages();
// Redirect to the page the user was trying to access before if set.
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>("returnUrl");
if (!string.IsNullOrEmpty(localStorageReturnUrl))
{
NavigationManager.NavigateTo(localStorageReturnUrl);
}
else
{
NavigationManager.NavigateTo("/");
}
return [];
}
}

View File

@@ -1,28 +1,28 @@
@page "/user/login"
@attribute [AllowAnonymous]
@inherits AliasVault.WebApp.Auth.Pages.Base.LoginBase
@layout Auth.Layout.MainLayout
@inject HttpClient Http
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject AuthService AuthService
@using System.Text.Json
@attribute [AllowAnonymous]
@using AliasVault.Shared.Models
@using AliasVault.WebApp.Auth.Components
@using AliasVault.WebApp.Auth.Services
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Sign in to AliasVault
</h2>
<EditForm Model="_user" OnSubmit="HandleLogin" class="mt-8 space-y-6">
<FullScreenLoadingIndicator @ref="LoadingIndicator" />
<ServerValidationErrors @ref="ServerValidationErrors" />
<EditForm Model="LoginModel" OnValidSubmit="HandleLogin" class="mt-8 space-y-6">
<DataAnnotationsValidator/>
<div>
<label asp-for="Input.Email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your email</label>
<InputTextField id="email" @bind-Value="_user.Email" placeholder="name@company.com" />
<InputTextField id="email" @bind-Value="LoginModel.Email" placeholder="name@company.com" />
<ValidationMessage For="() => LoginModel.Email"/>
</div>
<div>
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your password</label>
<InputTextField id="password" @bind-Value="_user.Password" type="password" placeholder="••••••••" />
<InputTextField id="password" @bind-Value="LoginModel.Password" type="password" placeholder="••••••••" />
<ValidationMessage For="() => LoginModel.Password"/>
</div>
<div class="flex items-start">
@@ -41,12 +41,10 @@
</div>
</EditForm>
<FullScreenLoadingIndicator @ref="_loadingIndicator" />
@code {
private LoginModel _user = new LoginModel();
private FullScreenLoadingIndicator _loadingIndicator = new();
private readonly LoginModel LoginModel = new();
private FullScreenLoadingIndicator LoadingIndicator = new();
private ServerValidationErrors ServerValidationErrors = new();
/// <inheritdoc />
protected override async Task OnInitializedAsync()
@@ -61,37 +59,33 @@
private async Task HandleLogin()
{
_loadingIndicator.Show();
LoadingIndicator.Show();
ServerValidationErrors.Clear();
try
{
var result = await Http.PostAsJsonAsync("api/Auth/login", _user);
var responseContent = await result.Content.ReadAsStringAsync();
var tokenObject = JsonSerializer.Deserialize<TokenModel>(responseContent);
if (tokenObject != null)
var errors = await ProcessLoginAsync(LoginModel.Email, LoginModel.Password);
foreach (var error in errors)
{
// Store the token as a plain string in local storage
await AuthService.StoreAccessTokenAsync(tokenObject.Token);
await AuthService.StoreRefreshTokenAsync(tokenObject.RefreshToken);
await AuthStateProvider.GetAuthenticationStateAsync();
}
else
{
// Handle the case where the token is not present in the response
Console.WriteLine("Token not found in the response.");
}
await AuthStateProvider.GetAuthenticationStateAsync();
if (result.IsSuccessStatusCode)
{
NavigationManager.NavigateTo("/");
ServerValidationErrors.AddError(error);
}
}
#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("An error occurred while processing the login request. Try again (later).");
}
#endif
finally
{
_loadingIndicator.Hide();
LoadingIndicator.Hide();
}
}
}

View File

@@ -1,10 +1,11 @@
@page "/user/logout"
@using AliasVault.WebApp.Auth.Services
@attribute [AllowAnonymous]
@layout Auth.Layout.MainLayout
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject AuthService AuthService
@inject GlobalNotificationService GlobalNotificationService
@inject DbService DbService
@code {
/// <inheritdoc />
@@ -13,6 +14,9 @@
await base.OnInitializedAsync();
await AuthService.RemoveTokensAsync();
await AuthStateProvider.GetAuthenticationStateAsync();
// Initialize a new empty database to clear all data.
DbService.InitializeEmptyDatabase();
GlobalNotificationService.ClearMessages();
// Redirect to home page
NavigationManager.NavigateTo("/");

View File

@@ -8,32 +8,42 @@
@using System.Text.Json
@using AliasVault.Shared.Models
@using AliasVault.WebApp.Auth.Components
@using AliasVault.WebApp.Auth.Services
@using AliasVault.WebApp.Auth.Pages.Base
@using Cryptography
@using SecureRemotePassword
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Create a Free Account
Create a new AliasVault account
</h2>
<EditForm Model="user" OnSubmit="HandleRegister" class="mt-8 space-y-6">
<FullScreenLoadingIndicator @ref="LoadingIndicator" />
<ServerValidationErrors @ref="ServerValidationErrors" />
<EditForm Model="RegisterModel" OnValidSubmit="HandleRegister" class="mt-8 space-y-6">
<DataAnnotationsValidator/>
<div>
<label asp-for="Input.Email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your email</label>
<InputTextField id="email" @bind-Value="user.Email" placeholder="name@company.com" />
<InputTextField id="email" @bind-Value="RegisterModel.Email" placeholder="name@company.com" />
<ValidationMessage For="() => RegisterModel.Email"/>
</div>
<div>
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your password</label>
<InputTextField id="password" @bind-Value="user.Password" type="password" placeholder="••••••••" />
<InputTextField id="password" @bind-Value="RegisterModel.Password" type="password" placeholder="••••••••" />
<ValidationMessage For="() => RegisterModel.Password"/>
</div>
<div>
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm password</label>
<InputTextField id="password2" @bind-Value="user.PasswordConfirm" type="password" placeholder="••••••••" />
<InputTextField id="password2" @bind-Value="RegisterModel.PasswordConfirm" type="password" placeholder="••••••••" />
<ValidationMessage For="() => RegisterModel.PasswordConfirm"/>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input id="terms" aria-describedby="terms" name="terms" type="checkbox" class="w-4 h-4 border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:focus:ring-primary-600 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600" required>
<InputCheckbox id="terms" @bind-Value="RegisterModel.AcceptTerms" class="w-4 h-4 border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:focus:ring-primary-600 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600" />
</div>
<div class="ml-3 text-sm">
<label for="terms" class="font-medium text-gray-900 dark:text-white">I accept the <a href="#" class="text-primary-700 hover:underline dark:text-primary-500">Terms and Conditions</a></label>
<ValidationMessage For="() => RegisterModel.AcceptTerms"/>
</div>
</div>
@@ -43,79 +53,61 @@
</div>
</EditForm>
@if (validationErrors.Any())
{
<div class="alert alert-danger">
<ul>
@foreach (var error in validationErrors)
{
<li>@error</li>
}
</ul>
</div>
}
<FullScreenLoadingIndicator @ref="loadingIndicator" />
@code {
RegisterModel user = new();
FullScreenLoadingIndicator loadingIndicator = new();
List<string> validationErrors = [];
private readonly RegisterModel RegisterModel = new();
private FullScreenLoadingIndicator LoadingIndicator = new();
private ServerValidationErrors ServerValidationErrors = new();
async Task HandleRegister()
{
loadingIndicator.Show();
validationErrors.Clear();
LoadingIndicator.Show();
ServerValidationErrors.Clear();
try
{
var result = await Http.PostAsJsonAsync("api/Auth/register", user);
var client = new SrpClient();
var salt = client.GenerateSalt();
if (result.IsSuccessStatusCode)
byte[] passwordHash = await Encryption.DeriveKeyFromPasswordAsync(RegisterModel.Password, salt);
var passwordHashString = BitConverter.ToString(passwordHash).Replace("-", string.Empty);
var srpSignup = Cryptography.Srp.SignupPrepareAsync(client, salt, RegisterModel.Email, passwordHashString);
var result = await Http.PostAsJsonAsync("api/v1/Auth/register", srpSignup);
var responseContent = await result.Content.ReadAsStringAsync();
if (!result.IsSuccessStatusCode)
{
var responseContent = await result.Content.ReadAsStringAsync();
var tokenObject = JsonSerializer.Deserialize<TokenModel>(responseContent);
if (tokenObject != null)
foreach (var error in LoginBase.ParseResponse(responseContent))
{
// Store the token as a plain string in local storage
await AuthService.StoreAccessTokenAsync(tokenObject.Token);
await AuthService.StoreRefreshTokenAsync(tokenObject.RefreshToken);
await AuthStateProvider.GetAuthenticationStateAsync();
}
else
{
// Handle the case where the token is not present in the response
Console.WriteLine("Token not found in the response.");
ServerValidationErrors.AddError(error);
}
StateHasChanged();
return;
}
NavigationManager.NavigateTo("/");
var tokenObject = JsonSerializer.Deserialize<TokenModel>(responseContent);
if (tokenObject != null)
{
// Store the encryption key in memory.
AuthService.StoreEncryptionKey(passwordHash);
// Store the token as a plain string in local storage
await AuthService.StoreAccessTokenAsync(tokenObject.Token);
await AuthService.StoreRefreshTokenAsync(tokenObject.RefreshToken);
await AuthStateProvider.GetAuthenticationStateAsync();
}
else
{
var responseContent = await result.Content.ReadAsStringAsync();
var errorResponse = System.Text.Json.JsonSerializer.Deserialize<ValidationErrorResponse>(responseContent);
if (errorResponse != null && errorResponse.Errors != null)
{
foreach (var error in errorResponse.Errors.Values)
{
validationErrors.AddRange(error);
}
}
// Handle the case where the token is not present in the response
Console.WriteLine("Token not found in the response.");
}
NavigationManager.NavigateTo("/");
}
finally
{
loadingIndicator.Hide();
LoadingIndicator.Hide();
}
}
public class ValidationErrorResponse
{
public string Type { get; set; } = null!;
public string Title { get; set; } = null!;
public int Status { get; set; }
public Dictionary<string, string[]> Errors { get; set; } = new();
public string TraceId { get; set; } = null!;
}
}

View File

@@ -0,0 +1,103 @@
@page "/unlock"
@inherits AliasVault.WebApp.Auth.Pages.Base.LoginBase
@layout Auth.Layout.MainLayout
@using AliasVault.Shared.Models
@using AliasVault.WebApp.Auth.Components
<div class="flex space-x-4">
<img class="w-8 h-8 rounded-full" src="/img/avatar.webp" alt="Bonnie image">
<h2 class="mb-3 text-2xl font-bold text-gray-900 dark:text-white">@Email</h2>
</div>
<p class="text-base font-normal text-gray-500 dark:text-gray-400">
Enter your master password in order to unlock your database.
</p>
<FullScreenLoadingIndicator @ref="LoadingIndicator" />
<ServerValidationErrors @ref="ServerValidationErrors" />
<EditForm Model="UnlockModel" OnValidSubmit="HandleLogin" class="mt-8 space-y-6">
<DataAnnotationsValidator/>
<div>
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your password</label>
<InputTextField id="password" @bind-Value="UnlockModel.Password" type="password" placeholder="••••••••" />
<ValidationMessage For="() => UnlockModel.Password"/>
</div>
<button type="submit" class="inline-flex items-center justify-center w-full px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a5 5 0 00-5 5v2a2 2 0 00-2 2v5a2 2 0 002 2h10a2 2 0 002-2v-5a2 2 0 00-2-2H7V7a3 3 0 015.905-.75 1 1 0 001.937-.5A5.002 5.002 0 0010 2z"></path></svg>
Unlock
</button>
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
Switch accounts? <a href="/user/logout" class="text-primary-700 hover:underline dark:text-primary-500">Logout</a>
</div>
</EditForm>
@code {
private string? Email { get; set; }
private readonly UnlockModel UnlockModel = new();
private FullScreenLoadingIndicator LoadingIndicator = new();
private ServerValidationErrors ServerValidationErrors = new();
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await AuthStateProvider.GetAuthenticationStateAsync();
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (authState.User.Identity?.IsAuthenticated == false) {
// Not authenticated (anymore), redirect to login page.
NavigationManager.NavigateTo("/user/login");
}
var email = authState.User.Identity?.Name;
if (email is null)
{
// Clear all tokens and redirect to login page.
await AuthService.RemoveTokensAsync();
await AuthStateProvider.GetAuthenticationStateAsync();
GlobalNotificationService.ClearMessages();
NavigationManager.NavigateTo("/user/login");
}
Email = email;
}
private async Task HandleLogin()
{
if (Email == null)
{
return;
}
LoadingIndicator.Show();
ServerValidationErrors.Clear();
try
{
var errors = await ProcessLoginAsync(Email, UnlockModel.Password);
foreach (var error in errors)
{
ServerValidationErrors.AddError(error);
}
StateHasChanged();
}
#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("An error occurred while processing the login request. Try again (later).");
}
#endif
finally
{
LoadingIndicator.Hide();
}
}
}

View File

@@ -1,24 +0,0 @@
@if (faviconBytes != null)
{
<img src="@faviconDataUrl" style="width: 50px;" alt="Favicon" />
}
else
{
<img src="img/service-placeholder.webp" style="width: 50px;" alt="Favicon" />
}
@code {
[Parameter]
public byte[]? faviconBytes { get; set; }
private string? faviconDataUrl;
protected override void OnParametersSet()
{
if (faviconBytes != null)
{
string base64String = Convert.ToBase64String(faviconBytes);
faviconDataUrl = $"data:image/x-icon;base64,{base64String}";
}
}
}

View File

@@ -1,91 +0,0 @@
@using AliasVault.WebApp.Pages.Aliases.Models.Spamok
@using BlazorServer.Models.Spamok
@inherits ComponentBase
@inject IHttpClientFactory HttpClientFactory
<div class="flex justify-between">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Email</h3>
<button @onclick="LoadRecentEmailsAsync" type="button" class="text-blue-700 border border-blue-700 hover:bg-blue-700 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-full text-sm p-2.5 text-center inline-flex items-center dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:focus:ring-blue-800 dark:hover:bg-blue-500">
Refresh
</button>
</div>
@if (IsLoading)
{
<LoadingIndicator />
}
else if (MailboxEmails.Count == 0)
{
<div>No emails found.</div>
}
else
{
<div class="flex flex-col mt-6">
<div class="overflow-x-auto rounded-lg">
<div class="inline-block min-w-full align-middle">
<div class="overflow-hidden shadow sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Subject
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Date &amp; Time
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800">
@foreach (var mail in MailboxEmails)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
<a target="_blank" href="https://spamok.com/@mail.ToLocal/@mail.Id">@(mail.Subject.Substring(0,mail.Subject.Length > 30 ? 30 : mail.Subject.Length))...</a>
</td>
<td class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400">
<a target="_blank" href="https://spamok.com/@mail.ToLocal/@mail.Id">@mail.DateSystem</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
}
@code {
[Parameter]
public string EmailPrefix { get; set; } = string.Empty;
[Parameter]
public List<MailboxEmailApiModel> MailboxEmails { get; set; } = new List<MailboxEmailApiModel>();
public bool IsLoading { get; set; } = true;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await LoadRecentEmailsAsync();
}
}
private async Task LoadRecentEmailsAsync()
{
IsLoading = true;
StateHasChanged();
var client = HttpClientFactory.CreateClient("EmailClient");
MailboxApiModel? mailbox = await client.GetFromJsonAsync<MailboxApiModel>($"https://api.spamok.com/v2/EmailBox/{EmailPrefix}");
if (mailbox?.Mails != null)
{
MailboxEmails = mailbox.Mails;
}
IsLoading = false;
StateHasChanged();
}
}

View File

@@ -7,10 +7,16 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Install Python which is required by the WebAssembly tools
RUN apt-get update && apt-get install -y python3 && apt-get clean
# Install the WebAssembly tools
RUN dotnet workload install wasm-tools
# Copy the project files and restore dependencies
COPY ["src/AliasVault.WebApp/AliasVault.WebApp.csproj", "src/AliasVault.WebApp/"]
COPY ["src/AliasVault.Shared/AliasVault.Shared.csproj", "src/AliasVault.Shared/"]
COPY ["src/AliasDb/AliasDb.csproj", "src/AliasDb/"]
COPY ["src/Databases/AliasServerDb/AliasServerDb.csproj", "src/Databases/AliasServerDb/"]
COPY ["src/AliasGenerators/AliasGenerators.csproj", "src/AliasGenerators/"]
COPY ["src/Utilities/FaviconExtractor/FaviconExtractor.csproj", "src/Utilities/FaviconExtractor/"]
RUN dotnet restore "src/AliasVault.WebApp/AliasVault.WebApp.csproj"
@@ -20,12 +26,12 @@ COPY . .
# Build the WebApp project
WORKDIR "/src/src/AliasVault.WebApp"
RUN dotnet build "AliasVault.WebApp.csproj" -c $BUILD_CONFIGURATION -o /app/build
RUN dotnet build "AliasVault.WebApp.csproj" -c "$BUILD_CONFIGURATION" -o /app/build
# Publish the WebApp project
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "AliasVault.WebApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN dotnet publish "AliasVault.WebApp.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
# Final stage: start nginx and serve static html files that were published in the previous stage
FROM nginx:alpine AS final

View File

@@ -0,0 +1,15 @@
// -----------------------------------------------------------------------
// <copyright file="GlobalUsings.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
// -----------------------------------------------------------------------
// <auto-generated/>
// Note: this file is not actually auto-generated but the statement above is required because
// otherwise a lot of SA1200 checks will fail due to it not fully supporting GlobalUsings at time of writing.
global using AliasVault.WebApp.Main.Models;
global using AliasVault.WebApp.Services;
global using AliasVault.WebApp.Services.Auth;
global using AliasVault.WebApp.Services.Database;

View File

@@ -1,14 +0,0 @@
@inherits LayoutComponentBase
<TopMenu />
<div class="flex pt-16 overflow-hidden bg-gray-50 dark:bg-gray-900">
<div id="main-content" class="relative w-full max-w-screen-2xl mx-auto h-full overflow-y-auto bg-gray-50 dark:bg-gray-900">
<main>
@Body
</main>
<Footer />
</div>
</div>

View File

@@ -1,77 +0,0 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}

View File

@@ -1,39 +0,0 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">AliasVault</a>
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="oi oi-plus" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="fetchdata">
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink>
</div>
</nav>
</div>
@code {
private bool collapseNavMenu = true;
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}

View File

@@ -1,7 +1,6 @@
@using Microsoft.IdentityModel.Tokens
@inherits ComponentBase
@inherits ComponentBase
@if (Message.IsNullOrEmpty())
@if (Message == string.Empty)
{
return;
}
@@ -11,6 +10,9 @@
</div>
@code {
/// <summary>
/// The message to show.
/// </summary>
[Parameter]
public string Message { get; set; } = string.Empty;
}

View File

@@ -1,7 +1,6 @@
@using Microsoft.IdentityModel.Tokens
@inherits ComponentBase
@inherits ComponentBase
@if (Message.IsNullOrEmpty())
@if (Message == string.Empty)
{
return;
}
@@ -11,7 +10,9 @@
</div>
@code {
/// <summary>
/// The message to show.
/// </summary>
[Parameter]
public string Message { get; set; } = string.Empty;
}

View File

@@ -48,20 +48,20 @@
/// <summary>
/// Refreshes the messages by adding any new messages from the PortalMessageService.
/// </summary>
public void RefreshAddMessages()
private void RefreshAddMessages()
{
// We retrieve any additional messages from the GlobalNotificationService that we do not yet have.
var newMessages = GlobalNotificationService.GetMessagesForDisplay();
foreach (var message in newMessages)
{
if (!Messages.Any(m => m.Key == message.Key && m.Value == message.Value))
if (!Messages.Exists(m => m.Key == message.Key && m.Value == message.Value))
{
Messages.Add(message);
}
}
// Remove messages that are no longer in the GlobalNotificationService and have already been displayed.
var messagesToRemove = Messages.Where(m => !newMessages.Any(nm => nm.Key == m.Key && nm.Value == m.Value)).ToList();
var messagesToRemove = Messages.Where(m => !newMessages.Exists(nm => nm.Key == m.Key && nm.Value == m.Value)).ToList();
foreach (var message in messagesToRemove)
{
Messages.Remove(message);

View File

@@ -0,0 +1,31 @@
@using AliasVault.Shared.Models.WebApi
@if (_errors.Any())
{
@foreach (var error in _errors)
{
<AlertMessageError Message="@error" />
}
}
@code {
private readonly List<string> _errors = [];
/// <summary>
/// Adds a server validation error.
/// </summary>
public void AddError(string error)
{
_errors.Add(error);
StateHasChanged();
}
/// <summary>
/// Clears the server validation errors.
/// </summary>
public void Clear()
{
_errors.Clear();
StateHasChanged();
}
}

View File

@@ -0,0 +1,95 @@
@using System.IO
@inject IJSRuntime JSRuntime
<div class="col">
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Attachments</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<InputFile OnChange="@HandleFileSelection" class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400" />
@if (!string.IsNullOrEmpty(statusMessage))
{
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">@statusMessage</p>
}
@if (Attachments.Any())
{
<div class="mt-4">
<h4 class="mb-2 text-lg font-semibold dark:text-white">Attachments:</h4>
<ul class="list-disc list-inside">
@foreach (var attachment in Attachments)
{
<li class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>@attachment.Filename</span>
<button type="button" @onclick="() => DeleteAttachment(attachment)" class="text-red-500 hover:text-red-700">
Delete
</button>
</li>
}
</ul>
</div>
}
</div>
</div>
</div>
</div>
@code {
[Parameter]
public List<Attachment> Attachments { get; set; } = new List<Attachment>();
[Parameter]
public EventCallback<List<Attachment>> AttachmentsChanged { get; set; }
private string statusMessage = string.Empty;
private async Task HandleFileSelection(InputFileChangeEventArgs e)
{
statusMessage = "Uploading...";
foreach (var file in e.GetMultipleFiles(int.MaxValue))
{
try
{
using var ms = new MemoryStream();
await file.OpenReadStream().CopyToAsync(ms);
var attachment = new Attachment
{
Filename = file.Name,
Blob = ms.ToArray(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
Attachments.Add(attachment);
await AttachmentsChanged.InvokeAsync(Attachments);
statusMessage = "File uploaded successfully.";
}
catch (Exception ex)
{
statusMessage = $"Error uploading file: {ex.Message}";
await JSRuntime.InvokeVoidAsync("console.error", ex.Message);
}
}
StateHasChanged();
}
private async Task DeleteAttachment(Attachment attachment)
{
try
{
Attachments.Remove(attachment);
await AttachmentsChanged.InvokeAsync(Attachments);
statusMessage = "Attachment deleted successfully.";
}
catch (Exception ex)
{
statusMessage = $"Error deleting attachment: {ex.Message}";
await JSRuntime.InvokeVoidAsync("console.error", ex.Message);
}
StateHasChanged();
}
}

View File

@@ -0,0 +1,61 @@
@inject IJSRuntime JSRuntime
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Attachments</h3>
@if (Attachments.Any())
{
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3">Filename</th>
<th scope="col" class="px-6 py-3">Created At</th>
</tr>
</thead>
<tbody>
@foreach (var attachment in Attachments)
{
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
<span @onclick="() => DownloadAttachment(attachment)" class="text-primary cursor-pointer">@attachment.Filename</span>
</td>
<td class="px-6 py-4">
@attachment.CreatedAt.ToLocalTime().ToString("g")
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<p class="text-gray-500 dark:text-gray-400">No attachments available.</p>
}
</div>
@code {
[Parameter]
public ICollection<Attachment> Attachments { get; set; } = new List<Attachment>();
private async Task DownloadAttachment(Attachment attachment)
{
try
{
if (attachment.Blob != null)
{
await JSRuntime.InvokeVoidAsync("downloadFileFromStream", attachment.Filename, attachment.Blob);
}
else
{
// Handle the case where the attachment or its content is not found
await Console.Error.WriteLineAsync($"Attachment not found or has no content: {attachment.Id}");
}
}
catch (Exception ex)
{
// Handle any exceptions that occur during the download process
await Console.Error.WriteLineAsync($"Error downloading attachment: {ex.Message}");
}
}
}

View File

@@ -1,9 +1,9 @@
@inject NavigationManager NavigationManager
<div @onclick="ShowDetails" class="p-4 space-y-2 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700
<div @onclick="ShowDetails" class="credential-card p-4 space-y-2 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700
dark:bg-gray-800 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-200">
<div class="px-4 py-2 text-gray-400 rounded text-center flex flex-col items-center">
<DisplayFavicon faviconBytes="null"></DisplayFavicon>
<DisplayFavicon faviconBytes="@Obj.Logo"></DisplayFavicon>
<div>@Obj.Service</div>
<div>@Obj.CreateDate.ToShortDateString()</div>
</div>
@@ -11,14 +11,14 @@
@code {
/// <summary>
/// Gets or sets the alias object to show.
/// Gets or sets the credentials object to show.
/// </summary>
[Parameter]
public AliasVault.Shared.Models.WebApi.AliasListEntry Obj { get; set; } = null!;
public CredentialListEntry Obj { get; set; } = null!;
private void ShowDetails()
{
// Redirect to view page instead for now.
NavigationManager.NavigateTo($"/alias/{Obj.Id}");
NavigationManager.NavigateTo($"/credentials/{Obj.Id}");
}
}

View File

@@ -0,0 +1,28 @@
@if (FaviconBytes != null)
{
<img src="@_faviconDataUrl" style="width: 50px;" class="mb-4 rounded-lg w-28 sm:mb-0 xl:mb-4 2xl:mb-0" alt="Favicon" />
}
else
{
<img src="img/service-placeholder.webp" style="width: 50px;" alt="Favicon" />
}
@code {
/// <summary>
/// Byte[] of the favicon.
/// </summary>
[Parameter]
public byte[]? FaviconBytes { get; set; }
private string? _faviconDataUrl;
/// <inheritdoc />
protected override void OnParametersSet()
{
if (FaviconBytes is not null)
{
string base64String = Convert.ToBase64String(FaviconBytes);
_faviconDataUrl = $"data:image/x-icon;base64,{base64String}";
}
}
}

View File

@@ -0,0 +1,125 @@
@using AliasVault.WebApp.Main.Models.Spamok
@inherits ComponentBase
@inject IHttpClientFactory HttpClientFactory
@if (ShowComponent)
{
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex justify-between">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Email</h3>
<button @onclick="LoadRecentEmailsAsync" type="button" class="text-blue-700 border border-blue-700 hover:bg-blue-700 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-full text-sm p-2.5 text-center inline-flex items-center dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:focus:ring-blue-800 dark:hover:bg-blue-500">
Refresh
</button>
</div>
@if (IsLoading)
{
<LoadingIndicator/>
}
else if (MailboxEmails.Count == 0)
{
<div>No emails found.</div>
}
else
{
<div class="flex flex-col mt-6">
<div class="overflow-x-auto rounded-lg">
<div class="inline-block min-w-full align-middle">
<div class="overflow-hidden shadow sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Subject
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Date &amp; Time
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800">
@foreach (var mail in MailboxEmails)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
<a target="_blank" href="https://spamok.com/@mail.ToLocal/@mail.Id">@(mail.Subject.Substring(0, mail.Subject.Length > 30 ? 30 : mail.Subject.Length))...</a>
</td>
<td class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400">
<a target="_blank" href="https://spamok.com/@mail.ToLocal/@mail.Id">@mail.DateSystem</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
}
</div>
}
@code {
/// <summary>
/// The email address to show recent emails for.
/// </summary>
[Parameter]
public string Email { get; set; } = string.Empty;
private List<MailboxEmailApiModel> MailboxEmails { get; set; } = new();
private bool IsLoading { get; set; } = true;
private bool ShowComponent { get; set; } = false;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Check if email has a known SpamOK domain, if not, don't show this component.
if (Email.EndsWith("@landmail.nl"))
{
ShowComponent = true;
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!ShowComponent)
{
return;
}
if (firstRender)
{
await LoadRecentEmailsAsync();
}
}
private async Task LoadRecentEmailsAsync()
{
if (!ShowComponent)
{
return;
}
IsLoading = true;
StateHasChanged();
// Get email prefix, which is the part before the @ symbol.
string emailPrefix = Email.Split('@')[0];
var client = HttpClientFactory.CreateClient("EmailClient");
MailboxApiModel? mailbox = await client.GetFromJsonAsync<MailboxApiModel>($"https://api.spamok.com/v2/EmailBox/{emailPrefix}");
if (mailbox?.Mails != null)
{
MailboxEmails = mailbox.Mails;
}
IsLoading = false;
StateHasChanged();
}
}

View File

@@ -1,4 +1,5 @@
@using AliasVault.WebApp.Services
<!-- CopyPasteFormRow.razor -->
@inject ClipboardCopyService ClipboardCopyService
@inject IJSRuntime JsRuntime
@@ -20,7 +21,7 @@
[Parameter] public string Value { get; set; } = string.Empty;
private bool _copied => ClipboardCopyService.GetCopiedId() == _inputId;
private string _inputId = Guid.NewGuid().ToString();
private readonly string _inputId = Guid.NewGuid().ToString();
protected override void OnInitialized()
{

View File

@@ -1,10 +1,16 @@
@using AliasVault.WebApp.Services
@inject ClipboardCopyService ClipboardCopyService
@inject ClipboardCopyService ClipboardCopyService
@inject IJSRuntime JsRuntime
<label for="@Id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Label</label>
<div class="relative">
<input type="text" id="@Id" class="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" value="@Value" @oninput="OnInputChanged">
@if (Type == "textarea")
{
<textarea id="@Id" style="height: 200px;" class="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" value="@Value" @oninput="OnInputChanged"></textarea>
}
else
{
<input type="text" id="@Id" class="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" value="@Value" @oninput="OnInputChanged">
}
</div>
@code {
@@ -20,6 +26,12 @@
[Parameter]
public string Label { get; set; } = "Value";
/// <summary>
/// Type of input field.
/// </summary>
[Parameter]
public string Type { get; set; } = "text";
/// <summary>
/// Value of the input field.
/// </summary>

View File

@@ -1,7 +1,6 @@
@inherits ComponentBase
@using Microsoft.IdentityModel.Tokens
<nav class="flex mb-5" aria-label="RecentEmails">
<nav class="flex mb-5">
<ol class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2">
<li class="inline-flex items-center">
<a href="/" class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-primary-500">
@@ -11,7 +10,7 @@
</li>
@foreach (var item in BreadcrumbItems)
{
@if (!item.Url.IsNullOrEmpty())
@if (item.Url is not null)
{
<li>
<div class="flex items-center">
@@ -46,7 +45,7 @@
{
base.OnInitialized();
// Remove first item if it is the home page
if (BreadcrumbItems.Any() && BreadcrumbItems.First().DisplayName == "Home")
if (BreadcrumbItems.Any() && BreadcrumbItems[0].DisplayName == "Home")
{
BreadcrumbItems.RemoveAt(0);
}

View File

@@ -1,12 +1,15 @@
<div class="loading" style="display:@(IsVisible ? "block" : "none");">
<div class="spinner">
<div class="rect1"></div>
<div class="rect2"></div>
<div class="rect3"></div>
<div class="rect4"></div>
<div class="rect5"></div>
@if (IsVisible)
{
<div class="loading z-50">
<div class="spinner">
<div class="rect1"></div>
<div class="rect2"></div>
<div class="rect3"></div>
<div class="rect4"></div>
<div class="rect5"></div>
</div>
</div>
</div>
}
@code {
private bool IsVisible { get; set; }

View File

@@ -0,0 +1,22 @@
<div role="status" class="px-2" title="@Title">
<svg aria-hidden="true" class="inline w-7 h-7 text-gray-200 @(Spinning ? "animate-spin fill-primary-600" : "") dark:text-gray-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
</svg>
<span class="sr-only">Loading...</span>
</div>
@code {
/// <summary>
/// Optional title of the loading indicator.
/// </summary>
[Parameter]
public string Title { get; set; } = string.Empty;
/// <summary>
/// Set spinning to false to stop the animation.
/// </summary>
[Parameter]
public bool Spinning { get; set; } = true;
}

View File

@@ -0,0 +1,63 @@
@implements IDisposable
@inject DbService DbService
@if (Loading)
{
<div class="flex items-center justify-center">
<SmallLoadingIndicator Title="@LoadingIndicatorMessage" />
</div>
}
else
{
<SmallLoadingIndicator Title="@LoadingIndicatorMessage" Spinning="false" />
}
<!--
<p>Message: @DbService.GetState().CurrentState.Message</p>
<p>Last Updated: @DbService.GetState().CurrentState.LastUpdated</p>
-->
@code {
private bool Loading { get; set; } = false;
private string Message { get; set; } = "";
private string LoadingIndicatorMessage { get; set; } = "";
private bool DatabaseLoading { get; set; } = false;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
DbService.GetState().StateChanged += OnDatabaseStateChanged;
}
private async void OnDatabaseStateChanged(object? sender, DbServiceState.DatabaseState newState)
{
await InvokeAsync(StateHasChanged);
if (newState.Status == DbServiceState.DatabaseStatus.SavingToServer)
{
// Show loading indicator for at least 0.5 seconds even if the save operation is faster.
Message = "Saving...";
await ShowLoadingIndicatorAsync();
}
LoadingIndicatorMessage = Message + " - " + newState.LastUpdated;
}
private async Task ShowLoadingIndicatorAsync()
{
Loading = true;
StateHasChanged();
await Task.Delay(800);
Loading = false;
StateHasChanged();
}
/// <summary>
/// Dispose method.
/// </summary>
public void Dispose()
{
DbService.GetState().StateChanged -= OnDatabaseStateChanged;
}
}

View File

@@ -0,0 +1,16 @@
@inherits LayoutComponentBase
<CascadingAuthenticationState>
<AuthorizeView>
<Authorized>
<main>
@Body
</main>
</Authorized>
<NotAuthorized>
<main>
@Body
</main>
</NotAuthorized>
</AuthorizeView>
</CascadingAuthenticationState>

Some files were not shown because too many files have changed in this diff Show More