mirror of
https://github.com/aliasvault/aliasvault.git
synced 2025-12-24 06:39:12 -05:00
Compare commits
405 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43ddd394c2 | ||
|
|
f248f9cd26 | ||
|
|
24f6168a7e | ||
|
|
46b0d7b24b | ||
|
|
37bc557278 | ||
|
|
29d7b6dfdb | ||
|
|
ec84792034 | ||
|
|
250ab24654 | ||
|
|
9b8770bb20 | ||
|
|
192d35d2dd | ||
|
|
9d2f7a15dd | ||
|
|
95ca7ab014 | ||
|
|
d29a33a95e | ||
|
|
a2486b67d7 | ||
|
|
abcd887e49 | ||
|
|
f400387630 | ||
|
|
3613a0cae1 | ||
|
|
13e16d1a51 | ||
|
|
a2eb12d6b9 | ||
|
|
dbb0a33179 | ||
|
|
32bb4502d6 | ||
|
|
f12642873d | ||
|
|
e9b9d6c363 | ||
|
|
a92f074099 | ||
|
|
5fbaed57cc | ||
|
|
9e24442d84 | ||
|
|
7737a586ab | ||
|
|
ce97c26b05 | ||
|
|
fa52643d48 | ||
|
|
be3ea8e198 | ||
|
|
574dcb3048 | ||
|
|
bb20f16b0f | ||
|
|
8284241be6 | ||
|
|
a0fb68a68c | ||
|
|
2e1497795d | ||
|
|
ddeab13cc3 | ||
|
|
8841a51376 | ||
|
|
e8e380bd22 | ||
|
|
ce5ad91ef5 | ||
|
|
b253772152 | ||
|
|
b0cb9e9b8d | ||
|
|
085706eae5 | ||
|
|
f57dcfa894 | ||
|
|
1b9ff1754a | ||
|
|
86a49de886 | ||
|
|
f6473b263b | ||
|
|
eae0aaf2a5 | ||
|
|
e086ce3684 | ||
|
|
3401dcf402 | ||
|
|
30ec77ef56 | ||
|
|
5e6e05cfd3 | ||
|
|
d06187879b | ||
|
|
d5e6b7a5c7 | ||
|
|
609006d199 | ||
|
|
1eb6f46f3c | ||
|
|
ad03613316 | ||
|
|
d71194b3b8 | ||
|
|
7b43acec09 | ||
|
|
8e24686578 | ||
|
|
2d91fdfaed | ||
|
|
ce93c01039 | ||
|
|
0631daf61b | ||
|
|
50c3c64db6 | ||
|
|
5b22a41aa6 | ||
|
|
e2be93ec85 | ||
|
|
bc6c7a10dc | ||
|
|
bca0d3ee6b | ||
|
|
c860899f8e | ||
|
|
fba8c171b6 | ||
|
|
557deee352 | ||
|
|
9ec1d83de9 | ||
|
|
6760a9c89a | ||
|
|
86ccccb95d | ||
|
|
8cca485930 | ||
|
|
353631bcda | ||
|
|
2f79eabd1b | ||
|
|
0ef1a9b118 | ||
|
|
7a374d9730 | ||
|
|
faa578b5b5 | ||
|
|
d59757c8fb | ||
|
|
88e5142049 | ||
|
|
f6c9000bec | ||
|
|
d3b3281ca4 | ||
|
|
55178006c8 | ||
|
|
92b65bad20 | ||
|
|
443721dc75 | ||
|
|
997335205f | ||
|
|
2d4c865709 | ||
|
|
5f4290add1 | ||
|
|
f553f4e596 | ||
|
|
acdeb8bc28 | ||
|
|
030b90eb76 | ||
|
|
022a32358d | ||
|
|
75f71dcc43 | ||
|
|
dae7e1d065 | ||
|
|
d0b680c20c | ||
|
|
95bc774d2d | ||
|
|
11c680471f | ||
|
|
c5ccde298f | ||
|
|
c969cd738f | ||
|
|
be62fc9127 | ||
|
|
da5754d720 | ||
|
|
658d03bc02 | ||
|
|
862f013bda | ||
|
|
c0d250a35c | ||
|
|
26580f72ca | ||
|
|
3ff418945c | ||
|
|
c729a06e25 | ||
|
|
b62a13ce76 | ||
|
|
9dca684e4c | ||
|
|
5d7433674c | ||
|
|
458a336526 | ||
|
|
9e16e30ad0 | ||
|
|
de0cce7b64 | ||
|
|
7b315dc87a | ||
|
|
dcf04f040d | ||
|
|
6d795c6370 | ||
|
|
827f677af8 | ||
|
|
be96d45275 | ||
|
|
7ef6e3ad8f | ||
|
|
df83bdd329 | ||
|
|
9d1341e02a | ||
|
|
1f249af022 | ||
|
|
5f9f607290 | ||
|
|
16baa7ad74 | ||
|
|
c4197e6cd8 | ||
|
|
8aa2ec925a | ||
|
|
b3ac7064ef | ||
|
|
91f554187e | ||
|
|
e161478614 | ||
|
|
92e2de76ba | ||
|
|
5644f89db0 | ||
|
|
8ea8382c7d | ||
|
|
869c3c86be | ||
|
|
cd865d70c6 | ||
|
|
5bb2e3d5ce | ||
|
|
e80c351ee6 | ||
|
|
359fa1752f | ||
|
|
259de77a12 | ||
|
|
f15ef80839 | ||
|
|
62d2249f40 | ||
|
|
14845e77e0 | ||
|
|
fa664ea918 | ||
|
|
bbb168d764 | ||
|
|
d5ba3a63e4 | ||
|
|
afc605afd0 | ||
|
|
cdbe6c6e8c | ||
|
|
b184273456 | ||
|
|
fb7de645e5 | ||
|
|
35f35b8bbe | ||
|
|
4c5e312f11 | ||
|
|
708f6d7b9b | ||
|
|
5b72e181e6 | ||
|
|
4441c543cc | ||
|
|
933f8650ce | ||
|
|
26c910bf26 | ||
|
|
037919a548 | ||
|
|
771527c891 | ||
|
|
13c8709c19 | ||
|
|
043538054e | ||
|
|
d366dc3b0c | ||
|
|
35f1332138 | ||
|
|
ac040d90a8 | ||
|
|
0e50276308 | ||
|
|
1c0041326b | ||
|
|
8d13b1b0e3 | ||
|
|
a267d94b3e | ||
|
|
4ccbba5b4b | ||
|
|
daf25fcc12 | ||
|
|
fc8d365c49 | ||
|
|
e7e66a6285 | ||
|
|
ad26450d8b | ||
|
|
fcf0fb8605 | ||
|
|
da2efa7e8a | ||
|
|
4018d38148 | ||
|
|
6affa67561 | ||
|
|
0a543cec42 | ||
|
|
179faac0a0 | ||
|
|
4cfacc5012 | ||
|
|
a407a23101 | ||
|
|
df33d4abd4 | ||
|
|
28a5939f62 | ||
|
|
467b25104e | ||
|
|
8ee3cd0396 | ||
|
|
d471a61fbf | ||
|
|
df0413038e | ||
|
|
9180d600a6 | ||
|
|
8bea3d9336 | ||
|
|
1f88d5678b | ||
|
|
061e72210f | ||
|
|
ef8fa091b9 | ||
|
|
21e8171355 | ||
|
|
5509be5281 | ||
|
|
6c7645ea3d | ||
|
|
4bd3b5cb29 | ||
|
|
2e08de3546 | ||
|
|
956338f61f | ||
|
|
9f87861f88 | ||
|
|
db6357a845 | ||
|
|
adc82278b2 | ||
|
|
22945f6066 | ||
|
|
9646552e46 | ||
|
|
2ffc6c1f52 | ||
|
|
9338384649 | ||
|
|
b6cfc03b01 | ||
|
|
997bd8ce44 | ||
|
|
e8b0544735 | ||
|
|
f1ff5c1d54 | ||
|
|
90471c362f | ||
|
|
201f521d60 | ||
|
|
cb25be8962 | ||
|
|
a1291b1951 | ||
|
|
eed66e3c48 | ||
|
|
9544aab2ce | ||
|
|
809507d9c3 | ||
|
|
b3e88f9d99 | ||
|
|
eda6ad0e44 | ||
|
|
86eacb0ad8 | ||
|
|
06830baf3d | ||
|
|
e82faeba40 | ||
|
|
f2cf7b123d | ||
|
|
e499fa9ace | ||
|
|
d6e73251d8 | ||
|
|
7ee2984459 | ||
|
|
5b89f163de | ||
|
|
291b441d3f | ||
|
|
89751633f1 | ||
|
|
50464886f3 | ||
|
|
9d0fc082c0 | ||
|
|
c85bf5cebd | ||
|
|
4f49f343c9 | ||
|
|
226b5bfaff | ||
|
|
73013306d6 | ||
|
|
0b7c641e32 | ||
|
|
b7ab70e3de | ||
|
|
f09c27eefa | ||
|
|
0f4482487b | ||
|
|
edc537316f | ||
|
|
4667ff64e1 | ||
|
|
d3d6dc56b8 | ||
|
|
b5525f137a | ||
|
|
5c619d9553 | ||
|
|
693d419bd9 | ||
|
|
9bccaba360 | ||
|
|
c4e82205b6 | ||
|
|
ef9c9e690f | ||
|
|
a4cc75a3aa | ||
|
|
633cef3450 | ||
|
|
c7af544e25 | ||
|
|
43ae4625dd | ||
|
|
a53deeeebf | ||
|
|
f84f063155 | ||
|
|
7f411db4dd | ||
|
|
f5ae307fba | ||
|
|
f0989aa2d7 | ||
|
|
d8ea3fe73c | ||
|
|
a1c1e86059 | ||
|
|
c86a1f84db | ||
|
|
92b5df1cc8 | ||
|
|
3e7826607f | ||
|
|
06f09cdbf1 | ||
|
|
81ec09a2ed | ||
|
|
7977cd7394 | ||
|
|
71ad07fad0 | ||
|
|
77de70ba82 | ||
|
|
b97b2163d5 | ||
|
|
b2aed24d8a | ||
|
|
6e6f24417a | ||
|
|
725efcfa91 | ||
|
|
71c326bc55 | ||
|
|
4b1feca11d | ||
|
|
02f9571b8b | ||
|
|
9e3b08c50d | ||
|
|
84ac36b1e2 | ||
|
|
690547bbf2 | ||
|
|
8d00ee496f | ||
|
|
aea8cbf405 | ||
|
|
0d3a2032a2 | ||
|
|
d619976b10 | ||
|
|
72034391fb | ||
|
|
5cda059a91 | ||
|
|
3109135a17 | ||
|
|
eca61933bf | ||
|
|
df72068e5c | ||
|
|
f093958833 | ||
|
|
d98ac5e61d | ||
|
|
7a730ac944 | ||
|
|
a99e370b1c | ||
|
|
94ad6e9ea0 | ||
|
|
eaec5447f5 | ||
|
|
c3aee4df8f | ||
|
|
27b0820906 | ||
|
|
1330e78169 | ||
|
|
739c54d821 | ||
|
|
0e8e5bf2ad | ||
|
|
f235e72f01 | ||
|
|
9b250bf83f | ||
|
|
138ffcb7a6 | ||
|
|
7da152a412 | ||
|
|
ec4e2d2c80 | ||
|
|
984d8512e9 | ||
|
|
e2e0b81564 | ||
|
|
770ee60402 | ||
|
|
2aa7d1ce60 | ||
|
|
578dd9da87 | ||
|
|
b129a75255 | ||
|
|
7ec2594d7f | ||
|
|
e1f729f2ed | ||
|
|
e53d1931c5 | ||
|
|
4373e6fa62 | ||
|
|
5ecdf926b6 | ||
|
|
e47b109f9d | ||
|
|
be244b2c68 | ||
|
|
155a3ccd0b | ||
|
|
5a16495864 | ||
|
|
b0c74d3ce2 | ||
|
|
46360e2f4a | ||
|
|
f2378f8e7f | ||
|
|
ca6aa40850 | ||
|
|
b015e4a9d6 | ||
|
|
8dc4bcb06f | ||
|
|
466c181ad1 | ||
|
|
cd4dc918cb | ||
|
|
18cea13ddd | ||
|
|
ab5795101f | ||
|
|
600d7bcbda | ||
|
|
25b908e311 | ||
|
|
0269d584aa | ||
|
|
072e63e98f | ||
|
|
95949508ba | ||
|
|
1564df342a | ||
|
|
1b2a6029bb | ||
|
|
c131372e37 | ||
|
|
b830d90ba4 | ||
|
|
e6feafcb87 | ||
|
|
52e55e44f2 | ||
|
|
1945b15e2e | ||
|
|
4f8ab5da28 | ||
|
|
188b1cba94 | ||
|
|
5da1021088 | ||
|
|
21ae755018 | ||
|
|
ca3e35e066 | ||
|
|
7d6a5fa947 | ||
|
|
a9e41fa6b4 | ||
|
|
f6f33c2482 | ||
|
|
9ef078bd57 | ||
|
|
329281cd53 | ||
|
|
1db9fa5a37 | ||
|
|
8ba039ff25 | ||
|
|
0df0b2c3ff | ||
|
|
e058990e31 | ||
|
|
f7865e5d9c | ||
|
|
7aeb34ec5f | ||
|
|
1f9400e811 | ||
|
|
a3e46f28a3 | ||
|
|
01f026a3d3 | ||
|
|
8824db222b | ||
|
|
dce170cee1 | ||
|
|
ef7a11e27a | ||
|
|
75b22cfddf | ||
|
|
65342a2a8d | ||
|
|
18978b94be | ||
|
|
c989573565 | ||
|
|
67ce7da21a | ||
|
|
fb2972695a | ||
|
|
2f47f81af8 | ||
|
|
6d6ee8bf3f | ||
|
|
881eb58a35 | ||
|
|
80bc7cd223 | ||
|
|
87f494fea8 | ||
|
|
a24e533e4c | ||
|
|
ebb8b27f85 | ||
|
|
41c210e75a | ||
|
|
2a50a455d8 | ||
|
|
6896c4cd1d | ||
|
|
9560572a40 | ||
|
|
4dffb9c3c0 | ||
|
|
b8cb3c4d78 | ||
|
|
6f54b05d5a | ||
|
|
d051d69aea | ||
|
|
02f0c43cbd | ||
|
|
14cce42091 | ||
|
|
a1c26cec04 | ||
|
|
42fc1c018c | ||
|
|
f3e740bab3 | ||
|
|
bbdf47d6f4 | ||
|
|
5faf93d6be | ||
|
|
fa1573ee13 | ||
|
|
50f7866a0b | ||
|
|
7b1a1e893e | ||
|
|
40afea3908 | ||
|
|
e1ae260fc5 | ||
|
|
c33399b91d | ||
|
|
f46202223a | ||
|
|
0867573f2f | ||
|
|
2becb3aa8f | ||
|
|
dc2f4dd040 | ||
|
|
2cf3c142da | ||
|
|
a8d84fd38a | ||
|
|
4a207763cc | ||
|
|
b1ef5c33db | ||
|
|
578532efdf | ||
|
|
95fb8baaaa | ||
|
|
73e432b2dc | ||
|
|
f43c3171b0 |
@@ -1,4 +1,7 @@
|
||||
API_URL=
|
||||
JWT_KEY=
|
||||
DATA_PROTECTION_CERT_PASS=
|
||||
ADMIN_PASSWORD_HASH=
|
||||
ADMIN_PASSWORD_GENERATED=2024-01-01T00:00:00Z
|
||||
PRIVATE_EMAIL_DOMAINS=
|
||||
SMTP_TLS_ENABLED=false
|
||||
|
||||
53
.github/dependabot.yml
vendored
Normal file
53
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for NuGet
|
||||
- package-ecosystem: "nuget"
|
||||
directory: "/"
|
||||
target-branch: "main"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
# Check for updates once a week
|
||||
schedule:
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
interval: "weekly"
|
||||
# Ignore certain dependencies (optional)
|
||||
# ignore:
|
||||
# - dependency-name: "SomePackage"
|
||||
# versions: ["4.x", "5.x"]
|
||||
|
||||
# Enable version updates for npm
|
||||
- package-ecosystem: "npm"
|
||||
# Look for `package.json` and `lock` files in the `root` directory
|
||||
directory: "/"
|
||||
# Check for updates once a week
|
||||
schedule:
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
interval: "weekly"
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: "docker"
|
||||
# Look for a `Dockerfile` in the `root` directory
|
||||
directory: "/"
|
||||
# Check for updates once a week
|
||||
schedule:
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
interval: "weekly"
|
||||
|
||||
# Enable version updates for Composer
|
||||
- package-ecosystem: "composer"
|
||||
# Look for a `Dockerfile` in the `root` directory
|
||||
directory: "/"
|
||||
# Check for updates once a week
|
||||
schedule:
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
interval: "weekly"
|
||||
6
.github/release.yml
vendored
6
.github/release.yml
vendored
@@ -9,9 +9,9 @@ changelog:
|
||||
labels:
|
||||
- dependencies
|
||||
- bug
|
||||
- title: 🧩 Dependencies Updates
|
||||
labels:
|
||||
- dependencies
|
||||
- title: 🐞 Bug Fixes
|
||||
labels:
|
||||
- bug
|
||||
- title: 🧩 Dependencies Updates
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
99
.github/workflows/docker-compose-build.yml
vendored
99
.github/workflows/docker-compose-build.yml
vendored
@@ -9,61 +9,80 @@ on:
|
||||
jobs:
|
||||
test-docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
docker:
|
||||
image: docker:26.0.0
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set permissions and run install.sh
|
||||
run: |
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
|
||||
- name: Set up Docker Compose
|
||||
run: |
|
||||
# Change the exposed host port of the SmtpService from 25 to 2525 because port 25 is not allowed in GitHub Actions
|
||||
sed -i 's/25\:25/2525\:25/g' docker-compose.yml
|
||||
docker compose -f docker-compose.yml up -d
|
||||
|
||||
- name: Wait for services to be up
|
||||
run: |
|
||||
# Wait for a few seconds
|
||||
sleep 5
|
||||
- name: Test if localhost:80 (WASM app) responds
|
||||
run: |
|
||||
# Test if the service on localhost:80 responds
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:80)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "Service did not respond with 200 OK. Check if client app is configured correctly."
|
||||
exit 1
|
||||
else
|
||||
echo "Service responded with 200 OK"
|
||||
fi
|
||||
- name: Test if localhost:81 (WebApi) responds
|
||||
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 ]; then
|
||||
echo "Service did not respond with expected 200 OK. Check if WebApi is configured correctly."
|
||||
exit 1
|
||||
else
|
||||
echo "Service responded with $http_code"
|
||||
fi
|
||||
- name: Test if localhost:2525 (SmtpService) responds
|
||||
run: |
|
||||
# Test if the service on localhost:2525 responds
|
||||
if ! nc -zv localhost 2525 2>&1 | grep -q 'succeeded'; then
|
||||
echo "SmtpService did not respond on port 2525. Check if the SmtpService service is running."
|
||||
exit 1
|
||||
else
|
||||
echo "SmtpService responded on port 2525"
|
||||
fi
|
||||
- name: Test if localhost:8080 (Admin) responds
|
||||
run: |
|
||||
# Test if the service on localhost:8080 responds
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/user/login)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "Service did not respond with expected 200 OK. Check if admin app is configured correctly."
|
||||
exit 1
|
||||
else
|
||||
echo "Service responded with $http_code"
|
||||
fi
|
||||
sleep 10
|
||||
- name: Test if localhost:80 (WASM app) responds
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 2
|
||||
max_attempts: 3
|
||||
command: |
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:80)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "Service did not respond with 200 OK. Check if client app is configured correctly."
|
||||
exit 1
|
||||
else
|
||||
echo "Service responded with 200 OK"
|
||||
fi
|
||||
|
||||
- name: Test if localhost:81 (WebApi) responds
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 2
|
||||
max_attempts: 3
|
||||
command: |
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:81)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "Service did not respond with expected 200 OK. Check if WebApi is configured correctly."
|
||||
exit 1
|
||||
else
|
||||
echo "Service responded with $http_code"
|
||||
fi
|
||||
|
||||
- name: Test if localhost:2525 (SmtpService) responds
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 2
|
||||
max_attempts: 3
|
||||
command: |
|
||||
if ! nc -zv localhost 2525 2>&1 | grep -q 'succeeded'; then
|
||||
echo "SmtpService did not respond on port 2525. Check if the SmtpService service is running."
|
||||
exit 1
|
||||
else
|
||||
echo "SmtpService responded on port 2525"
|
||||
fi
|
||||
|
||||
- name: Test if localhost:8080 (Admin) responds
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 2
|
||||
max_attempts: 3
|
||||
command: |
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/user/login)
|
||||
if [ "$http_code" -ne 200 ]; then
|
||||
echo "Service did not respond with expected 200 OK. Check if admin app is configured correctly."
|
||||
exit 1
|
||||
else
|
||||
echo "Service responded with $http_code"
|
||||
fi
|
||||
|
||||
42
.github/workflows/dotnet-e2e-admin-tests.yml
vendored
Normal file
42
.github/workflows/dotnet-e2e-admin-tests.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: .NET E2E Admin Tests (Playwright)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
admin-tests:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.304
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Build
|
||||
run: dotnet build
|
||||
|
||||
- name: Ensure browsers are installed
|
||||
run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps
|
||||
|
||||
- name: Run AdminTests with retry
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 60
|
||||
max_attempts: 3
|
||||
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=AdminTests"
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: admin-test-results
|
||||
path: TestResults-Admin.xml
|
||||
39
.github/workflows/dotnet-e2e-client-tests.yml
vendored
Normal file
39
.github/workflows/dotnet-e2e-client-tests.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: .NET E2E Client Tests (Playwright with Sharding)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
client-tests:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.304
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Build
|
||||
run: dotnet build
|
||||
|
||||
- name: Ensure browsers are installed
|
||||
run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps
|
||||
|
||||
- name: Run ClientTests with retry (Shard ${{ matrix.shard }})
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 60
|
||||
max_attempts: 3
|
||||
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "FullyQualifiedName~.E2ETests.Tests.Client.Shard${{ matrix.shard }}."
|
||||
42
.github/workflows/dotnet-e2e-misc-tests.yml
vendored
Normal file
42
.github/workflows/dotnet-e2e-misc-tests.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: .NET E2E Misc Tests (Playwright)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
misc-tests:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.304
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Build
|
||||
run: dotnet build
|
||||
|
||||
- name: Ensure browsers are installed
|
||||
run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps
|
||||
|
||||
- name: Run remaining tests with retry
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 60
|
||||
max_attempts: 3
|
||||
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category!=AdminTests&Category!=ClientTests"
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: misc-test-results
|
||||
path: TestResults-Misc.xml
|
||||
48
.github/workflows/dotnet-e2e-tests.yml
vendored
48
.github/workflows/dotnet-e2e-tests.yml
vendored
@@ -1,48 +0,0 @@
|
||||
# This workflow will build a .NET project
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
||||
|
||||
name: .NET E2E Tests (Playwright)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup .NET
|
||||
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
|
||||
run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps
|
||||
|
||||
- name: Run AdminTests with retry
|
||||
uses: nick-invision/retry@v2
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=AdminTests"
|
||||
|
||||
- name: Run ClientTests with retry
|
||||
uses: nick-invision/retry@v2
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=ClientTests"
|
||||
|
||||
- name: Run remaining tests with retry
|
||||
uses: nick-invision/retry@v2
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category!=AdminTests&Category!=ClientTests"
|
||||
@@ -15,13 +15,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 8.0.304
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Build
|
||||
run: dotnet build
|
||||
|
||||
- name: Run integration tests
|
||||
run: dotnet test src/Tests/AliasVault.IntegrationTests --no-build --verbosity normal
|
||||
|
||||
7
.github/workflows/dotnet-unit-tests.yml
vendored
7
.github/workflows/dotnet-unit-tests.yml
vendored
@@ -14,15 +14,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 8.0.304
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --no-restore
|
||||
|
||||
- name: Run unittests
|
||||
run: dotnet test src/Tests/AliasVault.UnitTests --no-build --verbosity normal
|
||||
|
||||
@@ -15,15 +15,18 @@ jobs:
|
||||
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
|
||||
@@ -31,15 +34,17 @@ jobs:
|
||||
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
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
shell: powershell
|
||||
run: |
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -371,13 +371,24 @@ FodyWeavers.xsd
|
||||
.idea
|
||||
*.licenseheader
|
||||
|
||||
# AliasVault specific
|
||||
# Codebuddy Rider plugin
|
||||
.codebuddy
|
||||
|
||||
# -------------------
|
||||
# AliasVault specifics
|
||||
# -------------------
|
||||
# index.html is generated by the build process from index.template.html and therefore should be ignored
|
||||
src/AliasVault.Client/wwwroot/index.html
|
||||
|
||||
# appsettings.Development.json is generated by the build process from appsettings.Development.template.json and therefore should be ignored
|
||||
src/AliasVault.Client/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 install.sh and therefore should be ignored
|
||||
.env
|
||||
|
||||
# Draw.io diagram temp files
|
||||
*.drawio.*
|
||||
|
||||
|
||||
@@ -81,9 +81,21 @@ Here is an example file with the various options explained:
|
||||
{
|
||||
"ApiUrl": "http://localhost:5092",
|
||||
"PrivateEmailDomains": ["example.tld"],
|
||||
"UseDebugEncryptionKey": "true"
|
||||
"SupportEmail": "support@example.tld",
|
||||
"UseDebugEncryptionKey": "true",
|
||||
"CryptographyOverrideType" : "Argon2Id",
|
||||
"CryptographyOverrideSettings" : "{\"DegreeOfParallelism\":1,\"MemorySize\":1024,\"Iterations\":1}"
|
||||
}
|
||||
```
|
||||
|
||||
- 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.
|
||||
|
||||
- CryptographyOverrideType
|
||||
- This setting allows overriding the default encryption type (Argon2id) with a different encryption type. This is useful for testing different encryption types without having to change code.
|
||||
|
||||
- CryptographyOverrideSettings
|
||||
- This setting allows overriding the default encryption settings (Argon2id) with different settings. This is useful for testing different encryption settings without having to change code. The default Argon2id settings
|
||||
are defined in the project as `Utilities/Cryptography/Cryptography.Client/Defaults.cs`. These default settings
|
||||
are focused on security but NOT performance. Normally for key derivation purposes the slower/heavier the algorithm
|
||||
the better protection against attackers. For production builds this is what we want, however in case of automated testing or debugging extra performance can be gained by tweaking (lowering) these settings.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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.
|
||||
60
README.md
60
README.md
@@ -1,66 +1,67 @@
|
||||
<div align="center">
|
||||
|
||||
<h1>AliasVault</h1>
|
||||
<h1><img src="https://github.com/user-attachments/assets/933c8b45-a190-4df6-913e-b7c64ad9938b" width="40" /> AliasVault</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://app.aliasvault.net">Live demo 🚀</a> • <a href="https://aliasvault.net?utm_source=gh-readme">Website 🏠</a> • <a href="#installation">Installation 📦</a>
|
||||
</p>
|
||||
|
||||
<h3 align="center">
|
||||
Open-source password and alias manager
|
||||
</h3>
|
||||
|
||||
[<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-unit-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=integration 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-e2e-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
|
||||
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-e2e-client-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-e2e-client-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>
|
||||
|
||||
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 and alias 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.
|
||||
|
||||
### 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.
|
||||
- **Built-in email server**: AliasVault includes its own email server that allows you to generate virtual email addresses for each identity. Emails sent to these addresses are instantly visible in the AliasVault app.
|
||||
- **Virtual identities**: Generate virtual identities and assign them to a website, allowing you to use different email addresses and usernames for each website. Keeping your online identities separate and secure, making it harder for attackers to link your accounts.
|
||||
- **Built-in email server**: AliasVault includes its own email server that allows you to generate virtual email addresses for each alias. Emails sent to these addresses are instantly visible in the AliasVault app.
|
||||
- **Alias generation**: Generate aliases and assign them to a website, allowing you to use different email addresses and usernames for each website. Keeping your online identities separate and secure, making it harder for bad actors to link your accounts.
|
||||
- **Open-source**: The source code is available on GitHub and can be self-hosted on your own server.
|
||||
|
||||
> Note: AliasVault is currently in active development and some features may not yet have been (fully) implemented. If you run into any issues, please create an issue on GitHub.
|
||||
|
||||
## 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.
|
||||
A live demo of the app is available at the official website at [app.aliasvault.net](https://app.aliasvault.net) (up-to-date with `main` branch). You can create a free account to try it out yourself.
|
||||
|
||||
<img width="700" alt="Screenshot 2024-07-12 at 14 58 29" src="https://github.com/user-attachments/assets/57103f67-dff0-4124-9b33-62137aab5578">
|
||||
<img width="700" alt="Screenshot of AliasVault" src="docs/img/screenshot.png">
|
||||
|
||||
## 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.
|
||||
## Installation
|
||||
To install AliasVault on your local 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.
|
||||
### 1. Clone and run install script
|
||||
AliasVault comes with a install script that prepares the .env file, builds the Docker image, and starts the AliasVault containers.
|
||||
|
||||
```bash
|
||||
# Clone this Git repository to "AliasVault" directory
|
||||
$ git clone https://github.com/lanedirt/AliasVault.git
|
||||
```
|
||||
|
||||
### 2. Run the install script.
|
||||
The script prepares the .env file, builds the Docker image, and starts the AliasVault containers.
|
||||
|
||||
```bash
|
||||
# Go to the project directory
|
||||
$ cd AliasVault
|
||||
|
||||
# Make install script executable
|
||||
$ chmod +x install.sh
|
||||
|
||||
# Run the install script
|
||||
$ ./install.sh
|
||||
# Make install script executable and run it.
|
||||
$ chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
Note: if you do not wish to run the script, you can set up the environment variables and build the Docker image and containers manually instead. See the [manual setup instructions](docs/setup/1-manually-setup-docker.md) for more information.
|
||||
Note: if you do not wish to run the script, you can set up the environment variables and build the Docker image and containers manually instead. See the [manual setup instructions](docs/install/1-manually-setup-docker.md) for more information.
|
||||
|
||||
### 3. AliasVault is ready to use.
|
||||
The script will output the URL where the app is available. You can now open the app in your browser and create an account.
|
||||
### 2. Ready to use
|
||||
The install script executed in step #1 will output the URL where the app is available. By default this is http://localhost:80 for the client and http://localhost:8080 for the admin.
|
||||
|
||||
> Note: the container binds to port 80 for client and port 8080 for admin by default. If you have another service running on these ports, you can change the AliasVault ports in the `docker-compose.yml` file.
|
||||
> Note: If you want to change the default AliasVault ports you can do so in the `docker-compose.yml` file.
|
||||
|
||||
#### Note for first time build:
|
||||
- When running the init script for the first time, it may take a few minutes for Docker to download all dependencies. Subsequent builds will be faster.
|
||||
@@ -71,6 +72,17 @@ The script will output the URL where the app is available. You can now open the
|
||||
- To uninstall AliasVault, make the uninstall script executable with `chmod +x uninstall.sh` first, then run the script: `./uninstall.sh`.
|
||||
This will remove all containers, images, and volumes related to AliasVault. It will keep all files and configuration intact however, so you can easily reinstall AliasVault later.
|
||||
|
||||
## Security & Architecture
|
||||
AliasVault takes security seriously and implements various measures to protect your data:
|
||||
|
||||
- All sensitive user data is encrypted end-to-end using industry-standard encryption algorithms. This includes the complete vault contents and all received emails.
|
||||
- Your master password never leaves your device.
|
||||
- Zero-knowledge architecture ensures the server never has access to your unencrypted data
|
||||
|
||||
For detailed information about our encryption implementation and security architecture, see the following documents:
|
||||
- [SECURITY.md](SECURITY.md)
|
||||
- [Security Architecture (Diagram)](docs/security-architecture.md)
|
||||
|
||||
## Tech stack / credits
|
||||
The following technologies, frameworks and libraries are used in this project:
|
||||
|
||||
|
||||
95
SECURITY.md
Normal file
95
SECURITY.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# SECURITY.md
|
||||
This document describes the encryption algorithms used by AliasVault in order to keep its user data secure.
|
||||
|
||||
## Overview
|
||||
AliasVault features a [zero-knowledge architecture](https://en.wikipedia.org/wiki/Zero-knowledge_service) and uses a combination of encryption algorithms to protect the data of its users.
|
||||
|
||||
The basic premise is that the master password chosen by the user upon registration forms the basis for all encryption
|
||||
and decryption operations. This master password is never transmitted over the network and only resides on the client.
|
||||
All data is encrypted at rest and in transit. This ensures that even if the AliasVault servers are compromised,
|
||||
the user's data remains secure.
|
||||
|
||||
## Encryption algorithms
|
||||
The following encryption algorithms are used by AliasVault:
|
||||
|
||||
- [Argon2id](#argon2id)
|
||||
- [SRP](#srp)
|
||||
- [AES-GCM](#aes-gcm)
|
||||
- [RSA-OAEP](#rsa-oaep)
|
||||
|
||||
Below is a detailed explanation of each encryption algorithm.
|
||||
|
||||
For more information about how these algorithms are specifically used in AliasVault, see the [Security Architecture](docs/security-architecture.md) document.
|
||||
|
||||
### Argon2id
|
||||
To derive a key from the master password, AliasVault uses the Argon2id key derivation function. Argon2id is a memory-hard
|
||||
key derivation function which allows for controlling the execution time, memory required and degree of parallelism.
|
||||
This makes it resilient against brute-force attacks and makes it one of the best choices for deriving keys from passwords.
|
||||
|
||||
AliasVault uses Argon2id with the following default parameters:
|
||||
- Degree of parallelism: 1
|
||||
- Memory size: 19456 KB
|
||||
- Iterations: 2
|
||||
|
||||
More information about Argon2id can be found on the [Argon2](https://en.wikipedia.org/wiki/Argon2) Wikipedia page.
|
||||
|
||||
### SRP
|
||||
The Secure Remote Password (SRP) protocol is used for authenticating a user with the AliasVault server during login.
|
||||
The SRP protocol is a password-authenticated key exchange protocol (PAKE). This means that the client and server can
|
||||
authenticate each other using a password, without sending the password itself 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 without
|
||||
having ever seen the actual master password.
|
||||
|
||||
For more information see the [SRP protocol](https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol) information on Wikipedia.
|
||||
|
||||
### AES-256-GCM
|
||||
All user's vault data is fully encrypted on the client using the AES-256-GCM encryption algorithm, which stands for
|
||||
*Advanced Encryption Standard with 256-bit key in Galois/Counter Mode*. The key for encryption is derived from the
|
||||
master password by using the Argon2Id algorithm. AliasVault implements AES-GCM with the following specifications:
|
||||
|
||||
- Key Size: 256 bits
|
||||
- Uses the Web Crypto API's SubtleCrypto interface for secure cryptographic operations
|
||||
- Generates a random 12-byte (96-bit) IV (initialization vector) for each encryption operation
|
||||
- Performs all encryption/decryption operations entirely in the browser
|
||||
|
||||
#### The encryption process works as follows:
|
||||
- A unique IV is generated for each encryption operation
|
||||
- The users vault data is encrypted using AES-GCM with the derived key and IV
|
||||
- The IV is prepended to the ciphertext
|
||||
|
||||
More information about AES-GCM can be found on the [AES-GCM](https://en.wikipedia.org/wiki/Galois/Counter_Mode) Wikipedia page.
|
||||
|
||||
### RSA-OAEP
|
||||
To secure email communications, AliasVault uses RSA-OAEP (RSA with Optimal Asymmetric Encryption Padding). This asymmetric
|
||||
encryption system allows AliasVault to store emails on the server in encrypted state which can only be read by the
|
||||
intended recipient. AliasVault implements RSA-OAEP with the following specifications:
|
||||
- Algorithm: RSA-OAEP with SHA-256 hash
|
||||
- Key Size: 2048-bit modulus
|
||||
- Key Format: JWK (JSON Web Key)
|
||||
- Padding: OAEP (Optimal Asymmetric Encryption Padding)
|
||||
|
||||
#### Email Security Flow
|
||||
1. Key Generation: When a user creates a vault, a RSA key pair is generated:
|
||||
- A private key that remains in the encrypted user's vault and is never transmitted
|
||||
- A public key that is sent to the server
|
||||
|
||||
2. Email Reception Process: When an email arrives at the AliasVault email server:
|
||||
- The server generates a random 256-bit symmetric encryption key to encrypt the email contents
|
||||
- The symmetric encryption key is encrypted using the recipient's asymmetric public key
|
||||
- The encrypted email contents together with the encrypted symmetric encryption key are stored in the server's database
|
||||
- The original email content is never stored or logged
|
||||
|
||||
3. Email Retrieval Process:
|
||||
- When a user accesses their emails, the encrypted content is retrieved from the server
|
||||
- The client-side application decrypts the symmetric encryption key using the user's private key that is stored in their vault
|
||||
- The decrypted symmetric encryption key is used to decrypt the email contents
|
||||
- Decryption occurs entirely in the browser, maintaining end-to-end encryption
|
||||
|
||||
This implementation ensures that:
|
||||
- Emails are encrypted and secure at rest in the server database
|
||||
- Only the intended recipient that holds the private key can decrypt and read their emails
|
||||
- Even if the server is compromised, email contents remain encrypted and unreadable
|
||||
|
||||
More information about RSA-OAEP can be found on the [RSA-OAEP](https://en.wikipedia.org/wiki/Optimal_asymmetric_encryption_padding) Wikipedia page.
|
||||
@@ -3,21 +3,17 @@ 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}") = "AliasGenerators", "src\AliasGenerators\AliasGenerators.csproj", "{78E84B4E-57D1-491A-8F4E-9879AE49DE0F}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utilities", "Utilities", "{01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FaviconExtractor", "src\Utilities\FaviconExtractor\FaviconExtractor.csproj", "{ED328644-A152-403D-86EB-81201AA07744}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.FaviconExtractor", "src\Utilities\AliasVault.FaviconExtractor\AliasVault.FaviconExtractor.csproj", "{ED328644-A152-403D-86EB-81201AA07744}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.UnitTests", "src\Tests\AliasVault.UnitTests\AliasVault.UnitTests.csproj", "{8E6A418A-B305-465D-857D-49953605C18E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cryptography", "src\Utilities\Cryptography\Cryptography.csproj", "{427EA8E2-EA76-467E-A6BC-201EFE40C0D0}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.Api", "src\AliasVault.Api\AliasVault.Api.csproj", "{B797C533-260E-4DA2-83B1-0EE4BCFE08DB}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.Client", "src\AliasVault.Client\AliasVault.Client.csproj", "{25248E01-5A4B-4F95-A63C-BEA01499A1C2}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.Shared", "src\AliasVault.Shared\AliasVault.Shared.csproj", "{15EFE0D0-F41B-47D7-86B7-8F840335CB82}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.Shared", "src\Shared\AliasVault.Shared\AliasVault.Shared.csproj", "{15EFE0D0-F41B-47D7-86B7-8F840335CB82}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}"
|
||||
EndProject
|
||||
@@ -33,7 +29,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.E2ETests.Client.
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Server", "Server", "{607945F3-9896-4544-99EC-F3496CF4D36B}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CsvImportExport", "src\Utilities\CsvImportExport\CsvImportExport.csproj", "{A9C9A606-C87E-4298-AB32-09B1884D7487}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AliasVault.CsvImportExport", "src\Utilities\AliasVault.CsvImportExport\AliasVault.CsvImportExport.csproj", "{A9C9A606-C87E-4298-AB32-09B1884D7487}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{8A477241-B96C-4174-968D-D40CB77F1ECD}"
|
||||
EndProject
|
||||
@@ -43,24 +39,40 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.IntegrationTests
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Admin", "src\AliasVault.Admin\AliasVault.Admin.csproj", "{F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InitializationCLI", "src\Utilities\InitializationCLI\InitializationCLI.csproj", "{857BCD0E-753F-437A-AF75-B995B4D9A5FE}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.InstallCli", "src\Utilities\AliasVault.InstallCli\AliasVault.InstallCli.csproj", "{857BCD0E-753F-437A-AF75-B995B4D9A5FE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Logging", "src\Utilities\AliasVault.Logging\AliasVault.Logging.csproj", "{FF0B0E64-1AE2-415C-A404-0EB78010821A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.RazorComponents", "src\Utilities\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj", "{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.RazorComponents", "src\Shared\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj", "{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.WorkerStatus", "src\Utilities\AliasVault.WorkerStatus\AliasVault.WorkerStatus.csproj", "{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.TotpGenerator", "src\Utilities\AliasVault.TotpGenerator\AliasVault.TotpGenerator.csproj", "{E8D9C551-67D2-4651-8EDF-4262DF7375CE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Auth", "src\Utilities\AliasVault.Auth\AliasVault.Auth.csproj", "{DA175274-0FF7-4436-9266-742F96C2D1ED}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Cryptography", "Cryptography", "{BB7E701E-B1C6-453E-800A-E12CE256318D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Cryptography.Server", "src\Utilities\Cryptography\AliasVault.Cryptography.Server\AliasVault.Cryptography.Server.csproj", "{341EC443-0B6B-4E8C-AF46-D6156573CEA5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Cryptography.Client", "src\Utilities\Cryptography\AliasVault.Cryptography.Client\AliasVault.Cryptography.Client.csproj", "{542C7B7D-C2B4-4AE3-9B2C-C62FCF4DFF8E}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Generators", "Generators", "{03D55CA4-20B3-4FEA-9ADD-3C7B5B10E0FE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Generators.Password", "src\Generators\AliasVault.Generators.Password\AliasVault.Generators.Password.csproj", "{47F47A1B-49E0-406A-81C8-31FF2E4C339B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Generators.Identity", "src\Generators\AliasVault.Generators.Identity\AliasVault.Generators.Identity.csproj", "{80E74FBC-4EC8-45FB-B210-473337C484B5}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{DD359F0A-0180-4F8F-9E48-46213386BA4D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Shared.Core", "src\Shared\AliasVault.Shared.Core\AliasVault.Shared.Core.csproj", "{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{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
|
||||
{78E84B4E-57D1-491A-8F4E-9879AE49DE0F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ED328644-A152-403D-86EB-81201AA07744}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ED328644-A152-403D-86EB-81201AA07744}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ED328644-A152-403D-86EB-81201AA07744}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -69,10 +81,6 @@ Global
|
||||
{8E6A418A-B305-465D-857D-49953605C18E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8E6A418A-B305-465D-857D-49953605C18E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8E6A418A-B305-465D-857D-49953605C18E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{427EA8E2-EA76-467E-A6BC-201EFE40C0D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{427EA8E2-EA76-467E-A6BC-201EFE40C0D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{427EA8E2-EA76-467E-A6BC-201EFE40C0D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{427EA8E2-EA76-467E-A6BC-201EFE40C0D0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B797C533-260E-4DA2-83B1-0EE4BCFE08DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B797C533-260E-4DA2-83B1-0EE4BCFE08DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B797C533-260E-4DA2-83B1-0EE4BCFE08DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -133,6 +141,34 @@ Global
|
||||
{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E8D9C551-67D2-4651-8EDF-4262DF7375CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E8D9C551-67D2-4651-8EDF-4262DF7375CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E8D9C551-67D2-4651-8EDF-4262DF7375CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E8D9C551-67D2-4651-8EDF-4262DF7375CE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DA175274-0FF7-4436-9266-742F96C2D1ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DA175274-0FF7-4436-9266-742F96C2D1ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DA175274-0FF7-4436-9266-742F96C2D1ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DA175274-0FF7-4436-9266-742F96C2D1ED}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{341EC443-0B6B-4E8C-AF46-D6156573CEA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{341EC443-0B6B-4E8C-AF46-D6156573CEA5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{341EC443-0B6B-4E8C-AF46-D6156573CEA5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{341EC443-0B6B-4E8C-AF46-D6156573CEA5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{542C7B7D-C2B4-4AE3-9B2C-C62FCF4DFF8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{542C7B7D-C2B4-4AE3-9B2C-C62FCF4DFF8E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{542C7B7D-C2B4-4AE3-9B2C-C62FCF4DFF8E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{542C7B7D-C2B4-4AE3-9B2C-C62FCF4DFF8E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{47F47A1B-49E0-406A-81C8-31FF2E4C339B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{47F47A1B-49E0-406A-81C8-31FF2E4C339B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{47F47A1B-49E0-406A-81C8-31FF2E4C339B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{47F47A1B-49E0-406A-81C8-31FF2E4C339B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{80E74FBC-4EC8-45FB-B210-473337C484B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{80E74FBC-4EC8-45FB-B210-473337C484B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{80E74FBC-4EC8-45FB-B210-473337C484B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{80E74FBC-4EC8-45FB-B210-473337C484B5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -140,7 +176,6 @@ Global
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{ED328644-A152-403D-86EB-81201AA07744} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{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}
|
||||
@@ -151,8 +186,17 @@ Global
|
||||
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509} = {29DE523D-EEF2-41E9-AC12-F20D8D02BEBB}
|
||||
{857BCD0E-753F-437A-AF75-B995B4D9A5FE} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{FF0B0E64-1AE2-415C-A404-0EB78010821A} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{E8D9C551-67D2-4651-8EDF-4262DF7375CE} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{DA175274-0FF7-4436-9266-742F96C2D1ED} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{BB7E701E-B1C6-453E-800A-E12CE256318D} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
|
||||
{341EC443-0B6B-4E8C-AF46-D6156573CEA5} = {BB7E701E-B1C6-453E-800A-E12CE256318D}
|
||||
{542C7B7D-C2B4-4AE3-9B2C-C62FCF4DFF8E} = {BB7E701E-B1C6-453E-800A-E12CE256318D}
|
||||
{47F47A1B-49E0-406A-81C8-31FF2E4C339B} = {03D55CA4-20B3-4FEA-9ADD-3C7B5B10E0FE}
|
||||
{80E74FBC-4EC8-45FB-B210-473337C484B5} = {03D55CA4-20B3-4FEA-9ADD-3C7B5B10E0FE}
|
||||
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
|
||||
{15EFE0D0-F41B-47D7-86B7-8F840335CB82} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
|
||||
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {FEE82475-C009-4762-8113-A6563D9DC49E}
|
||||
|
||||
4
certificates/README.md
Normal file
4
certificates/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
This is the default location where (self-generated) certificates are stored.
|
||||
|
||||
For example, the API and Admin projects make use of the .NET DataProtection API that depends on
|
||||
certificates for encrypting various types of application data such as authentication cookies, anti-forgery tokens etc.
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
ports:
|
||||
- "8080:8082"
|
||||
volumes:
|
||||
- ./certificates:/certificates:rw
|
||||
- ./database:/database:rw
|
||||
- ./logs:/logs:rw
|
||||
restart: always
|
||||
@@ -31,6 +32,7 @@ services:
|
||||
ports:
|
||||
- "81:8081"
|
||||
volumes:
|
||||
- ./certificates:/certificates:rw
|
||||
- ./database:/database:rw
|
||||
- ./logs:/logs:rw
|
||||
env_file:
|
||||
|
||||
12
docs/client/enable-webauthn-pfr-chrome.md
Normal file
12
docs/client/enable-webauthn-pfr-chrome.md
Normal file
@@ -0,0 +1,12 @@
|
||||
The webauthn implementation in order to quick unlock the vault requires the use of a FIDO2 authenticator.
|
||||
|
||||
This can be either the built-in browser authenticator or an external authenticator like a Yubikey.
|
||||
|
||||
At the time of writing (2024-10-04), only some browsers support the required PRF extension. In order to make it work in Chrome, you need to enable the PRF extension in the browser settings.
|
||||
|
||||
## Chrome
|
||||
|
||||
1. Open the Chrome browser and navigate to `chrome://flags/#enable-experimental-web-platform-features`.
|
||||
2. Enable the `Experimental Web Platform features` flag.
|
||||
3. Restart the browser.
|
||||
4. Now it should be possible to use the built-in chrome password manager to unlock the vault.
|
||||
86
docs/dev/run-github-actions-locally.md
Normal file
86
docs/dev/run-github-actions-locally.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Run GitHub Actions Locally
|
||||
|
||||
This guide will help you set up and run GitHub Actions locally on Linux, which can be useful for debugging and testing your workflows without pushing changes to the repository.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Linux (Ubuntu or RHEL-based distributions)
|
||||
- [Docker](https://www.docker.com/) installed and running
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Install GitHub CLI
|
||||
|
||||
First, install the GitHub CLI using Homebrew:
|
||||
|
||||
```bash
|
||||
sudo dnf install 'dnf-command(config-manager)'
|
||||
sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo
|
||||
sudo dnf install gh --repo gh-cli
|
||||
```
|
||||
|
||||
### 2. Install Nektos/Act
|
||||
|
||||
Next, install the Nektos/Act extension for GitHub CLI:
|
||||
|
||||
```bash
|
||||
gh extension install https://github.com/nektos/gh-act
|
||||
```
|
||||
|
||||
## Basic usage
|
||||
|
||||
To run GitHub Actions locally, navigate to the root of your Git project and execute:
|
||||
|
||||
```bash
|
||||
act
|
||||
```
|
||||
|
||||
This command will pull the necessary Docker containers and execute the GitHub Actions defined in your repository.
|
||||
|
||||
### Understanding the `-P` Option
|
||||
|
||||
By default, `act` uses a simple Docker container that is small in size. However, official GitHub runners are much larger (10GB or even 100GB+). When certain commands or environments are needed, you should specify the full runner image using the `-P` option.
|
||||
|
||||
The `-P` option allows you to map the platform to a specific Docker image. This is particularly useful when you need to replicate the environment of the official GitHub runners more closely.
|
||||
|
||||
Syntax:
|
||||
```bash
|
||||
act -P ubuntu-latest=catthehacker/ubuntu:full-latest
|
||||
```
|
||||
|
||||
This command tells `act` to use the `catthehacker/ubuntu:full-latest` Docker image for the `ubuntu-latest` platform, which is a more complete representation of the GitHub-hosted runner environment.
|
||||
|
||||
## Debugging E2E Tests for AliasVault
|
||||
|
||||
To run and debug the E2E tests for AliasVault using a more complete runner image, use the following command:
|
||||
|
||||
```bash
|
||||
act -W .github/workflows/dotnet-e2e-tests.yml -P ubuntu-latest=catthehacker/ubuntu:full-latest
|
||||
```
|
||||
|
||||
This command does the following:
|
||||
- `-W .github/workflows/dotnet-e2e-tests.yml`: Specifies the workflow file to run
|
||||
- `-P ubuntu-latest=catthehacker/ubuntu:full-latest`: Uses a more complete Ubuntu image that better replicates the GitHub-hosted runner environment
|
||||
|
||||
Running this command will execute the E2E tests locally, allowing you to debug and test your workflow without pushing changes to the repository.
|
||||
|
||||
```bash
|
||||
docker image prune -a -f && docker system prune -a -f
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Handling Disk Space Errors
|
||||
|
||||
If you encounter disk space errors, you can free up space by pruning Docker images and system resources:
|
||||
|
||||
### Misc
|
||||
|
||||
If you encounter any issues while running GitHub Actions locally, consider the following:
|
||||
|
||||
1. Ensure Docker is running and has sufficient resources allocated.
|
||||
2. Check that your workflow file is correctly formatted and placed in the `.github/workflows/` directory.
|
||||
3. Verify that all required secrets and environment variables are properly set.
|
||||
4. If you're using specific tools or commands that are available in GitHub-hosted runners but not in the default `act` image, make sure to use the `-P` option with an appropriate image as shown in the E2E tests example.
|
||||
|
||||
For more detailed information and advanced usage, refer to the [Nektos/Act GitHub repository](https://github.com/nektos/act).
|
||||
30
docs/diagrams/README.md
Normal file
30
docs/diagrams/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Diagrams
|
||||
|
||||
This folder contains architecture and flow diagrams for AliasVault in various formats.
|
||||
|
||||
## Draw.io Diagrams (.drawio)
|
||||
Files with `.drawio` extension are created with Draw.io (also known as diagrams.net), an open-source diagramming tool.
|
||||
|
||||
### How to Open/Edit Draw.io Files
|
||||
1. Web Interface (Cloud)
|
||||
- Visit [diagrams.net](https://app.diagrams.net/)
|
||||
- Open source code available at [github.com/jgraph/drawio](https://github.com/jgraph/drawio)
|
||||
|
||||
2. Desktop Applications (Offline)
|
||||
- Available for Windows, macOS, and Linux
|
||||
- Download from [github.com/jgraph/drawio-desktop/releases](https://github.com/jgraph/drawio-desktop/releases)
|
||||
- Open source code available at [github.com/jgraph/drawio-desktop](https://github.com/jgraph/drawio-desktop)
|
||||
|
||||
3. VS Code Extension
|
||||
- Install the [Draw.io Integration](https://marketplace.visualstudio.com/items?itemName=hediet.vscode-drawio) extension
|
||||
- Edit diagrams directly within VS Code
|
||||
- Source code at [github.com/hediet/vscode-drawio](https://github.com/hediet/vscode-drawio)
|
||||
|
||||
## Mermaid Diagrams (.mmd)
|
||||
Files with `.mmd` extension are [Mermaid](https://mermaid.js.org/) format diagrams. These are text-based diagram definitions that can be rendered by various tools.
|
||||
|
||||
### Editors & Tools for Mermaid
|
||||
- [Mermaid Live Editor](https://github.com/mermaid-js/mermaid-live-editor) - Web-based editor with live preview
|
||||
- [VS Code Mermaid Extension](https://github.com/mermaid-js/vscode-mermaid) - Preview and edit Mermaid diagrams directly in VS Code
|
||||
- [Obsidian Mermaid Plugin](https://github.com/jobindj/obsidian-mermaid) - If you use Obsidian for documentation
|
||||
- [GitLab](https://docs.gitlab.com/ee/user/markdown.html#mermaid) and [GitHub](https://github.blog/2022-02-14-include-diagrams-markdown-files-mermaid/) both render Mermaid diagrams natively in markdown
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1024 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,452 @@
|
||||
<mxfile host="Electron" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/24.7.17 Chrome/128.0.6613.36 Electron/32.0.1 Safari/537.36" version="24.7.17">
|
||||
<diagram name="Page-1" id="ykhTdbPCDOXpVAqZYsCj">
|
||||
<mxGraphModel dx="1775" dy="1249" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1654" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-1" value="Legend" style="shape=table;startSize=30;container=1;collapsible=0;childLayout=tableLayout;fontSize=16;align=left;verticalAlign=top;fillColor=#0050ef;strokeColor=#001DBC;fontColor=#ffffff;fontStyle=1;spacingLeft=6;spacing=0;resizable=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="275" y="1058" width="723" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-2" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontSize=12;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-1">
|
||||
<mxGeometry y="30" width="723" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-3" value="Cryptographic Operations" style="shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=#432D57;overflow=hidden;fillColor=#76608a;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=12;align=left;fontColor=#ffffff;spacingLeft=10;spacingRight=4;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-2">
|
||||
<mxGeometry width="163" height="30" as="geometry">
|
||||
<mxRectangle width="163" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-203" value="Storage Elements" style="shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=#006EAF;overflow=hidden;fillColor=#1ba1e2;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=12;align=left;fontColor=#ffffff;spacingLeft=10;spacingRight=4;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-2">
|
||||
<mxGeometry x="163" width="120" height="30" as="geometry">
|
||||
<mxRectangle width="120" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-208" value="<span style="color: rgb(0, 0, 0);">Keys and Sensitive Data</span>" style="shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=#B09500;overflow=hidden;fillColor=#e3c800;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=12;align=left;fontColor=#000000;spacingLeft=10;spacingRight=4;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-2">
|
||||
<mxGeometry x="283" width="170" height="30" as="geometry">
|
||||
<mxRectangle width="170" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-213" value="Authentication Steps" style="shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=#2D7600;overflow=hidden;fillColor=#60a917;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=12;align=left;fontColor=#ffffff;spacingLeft=10;spacingRight=4;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-2">
|
||||
<mxGeometry x="453" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-218" value="Process step" style="shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=#6D1F00;overflow=hidden;fillColor=#a0522d;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=12;align=left;fontColor=#ffffff;spacingLeft=10;spacingRight=4;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-2">
|
||||
<mxGeometry x="603" width="120" height="30" as="geometry">
|
||||
<mxRectangle width="120" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-15" value="Client (WebAssembly)" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="52" width="1340" height="470" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-63" value="Vault operations" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="526.84" y="82" width="160" height="320" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-32" value="Server (REST API)" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="652" width="1340" height="390" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-43" value="" style="group;fillColor=#76608a;fontColor=#ffffff;strokeColor=#432D57;" vertex="1" connectable="0" parent="1">
|
||||
<mxGeometry x="70" y="682.65" width="410" height="320" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-33" value="Authentication flow" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-43">
|
||||
<mxGeometry width="410" height="320" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-34" value="SRP server verification" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#76608a;strokeColor=#432D57;fontColor=#ffffff;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-43">
|
||||
<mxGeometry x="150" y="40" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-35" value="2FA (Optional)" style="text;html=1;align=center;verticalAlign=top;whiteSpace=wrap;rounded=0;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-43">
|
||||
<mxGeometry x="16" y="120" width="378" height="90" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-81" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="6F2B19X3ZkVbRV3rCgbW-43" source="6F2B19X3ZkVbRV3rCgbW-36" target="6F2B19X3ZkVbRV3rCgbW-37">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-36" value="Google Authenticator or compatible" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-43">
|
||||
<mxGeometry x="34" y="155" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-82" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="6F2B19X3ZkVbRV3rCgbW-43" source="6F2B19X3ZkVbRV3rCgbW-37" target="6F2B19X3ZkVbRV3rCgbW-38">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-37" value="Time-based OTP" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-43">
|
||||
<mxGeometry x="154" y="155" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-38" value="Verify OTP code" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-43">
|
||||
<mxGeometry x="274" y="155" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-39" value="Issue JWT token" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#60a917;strokeColor=#2D7600;fontColor=#ffffff;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-43">
|
||||
<mxGeometry x="150" y="239.35" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-40" value="" style="endArrow=classic;html=1;rounded=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="6F2B19X3ZkVbRV3rCgbW-43" source="6F2B19X3ZkVbRV3rCgbW-34" target="6F2B19X3ZkVbRV3rCgbW-35">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="-256" y="440" as="sourcePoint" />
|
||||
<mxPoint x="-206" y="390" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-41" value="" style="endArrow=classic;html=1;rounded=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="6F2B19X3ZkVbRV3rCgbW-43" source="6F2B19X3ZkVbRV3rCgbW-35" target="6F2B19X3ZkVbRV3rCgbW-39">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="215" y="100" as="sourcePoint" />
|
||||
<mxPoint x="215" y="130" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-44" value="" style="endArrow=classic;html=1;rounded=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-26" target="6F2B19X3ZkVbRV3rCgbW-66">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="369" y="640" as="sourcePoint" />
|
||||
<mxPoint x="419" y="590" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-45" value="SRP handshake" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-44">
|
||||
<mxGeometry x="-0.1654" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="8" y="15" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-27" value="AES256-GCM" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#76608a;fontColor=#ffffff;strokeColor=#432D57;" vertex="1" parent="1">
|
||||
<mxGeometry x="554" y="222" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-26" value="SRP client" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#76608a;fontColor=#ffffff;strokeColor=#432D57;" vertex="1" parent="1">
|
||||
<mxGeometry x="164" y="453" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-60" value="Key derivation and server authentication" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="95" y="82" width="250" height="320" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-16" value="Master Password <br>(not persisted)" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#e3c800;fontColor=#000000;strokeColor=#B09500;" vertex="1" parent="1">
|
||||
<mxGeometry x="145" y="172" width="150" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-20" value="Argon2Id" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#76608a;fontColor=#ffffff;strokeColor=#432D57;" vertex="1" parent="1">
|
||||
<mxGeometry x="165" y="247" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-22" value="" style="endArrow=classic;html=1;rounded=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-16" target="6F2B19X3ZkVbRV3rCgbW-20">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="405" y="282" as="sourcePoint" />
|
||||
<mxPoint x="455" y="232" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-23" value="Derived Key <br>(stored in app memory)" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#e3c800;fontColor=#000000;strokeColor=#B09500;" vertex="1" parent="1">
|
||||
<mxGeometry x="144" y="320" width="150" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-24" value="" style="endArrow=classic;html=1;rounded=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-20" target="6F2B19X3ZkVbRV3rCgbW-23">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="405" y="282" as="sourcePoint" />
|
||||
<mxPoint x="455" y="232" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-28" value="" style="endArrow=classic;html=1;rounded=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;curved=1;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-23" target="6F2B19X3ZkVbRV3rCgbW-26">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="420" y="552" as="sourcePoint" />
|
||||
<mxPoint x="470" y="502" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-30" value="Used for authentication<br>with server" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-28">
|
||||
<mxGeometry x="-0.1756" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="-2" y="24" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-67" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-66" target="6F2B19X3ZkVbRV3rCgbW-34">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-66" value="SRP salt/verifier" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#e3c800;fontColor=#000000;strokeColor=#B09500;" vertex="1" parent="1">
|
||||
<mxGeometry x="144" y="559" width="150" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-72" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-68" target="6F2B19X3ZkVbRV3rCgbW-27">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="609" y="392" />
|
||||
<mxPoint x="609" y="392" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-180" value="Decrypt with<br>derived key" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-72">
|
||||
<mxGeometry x="0.6371" y="-1" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-68" value="Retrieve encrypted vault" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#a0522d;fontColor=#ffffff;strokeColor=#6D1F00;" vertex="1" parent="1">
|
||||
<mxGeometry x="549" y="330" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-47" value="Server storage" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;container=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="682.65" width="140" height="320" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-70" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-56" target="6F2B19X3ZkVbRV3rCgbW-68">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-181" value="Retrieve vault <br>from server" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-70">
|
||||
<mxGeometry x="0.2454" y="1" relative="1" as="geometry">
|
||||
<mxPoint y="-26" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-56" value="Encrypted Vault(s)" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;fillColor=#1ba1e2;fontColor=#ffffff;strokeColor=#006EAF;container=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="556" y="712.65" width="110" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-76" value="Claiming new email address" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="720" y="82" width="250" height="320" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-83" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-77" target="6F2B19X3ZkVbRV3rCgbW-79">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-100" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-77" target="6F2B19X3ZkVbRV3rCgbW-78">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-77" value="RSA/OAEP Key Generation" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#76608a;fontColor=#ffffff;strokeColor=#432D57;" vertex="1" parent="1">
|
||||
<mxGeometry x="790" y="122" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-78" value="Public key" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#e3c800;fontColor=#000000;strokeColor=#B09500;" vertex="1" parent="1">
|
||||
<mxGeometry x="852" y="202" width="100" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-79" value="Private key" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#e3c800;fontColor=#000000;strokeColor=#B09500;" vertex="1" parent="1">
|
||||
<mxGeometry x="742" y="202" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-86" value="" style="group;fillColor=#1ba1e2;fontColor=#ffffff;strokeColor=#006EAF;" vertex="1" connectable="0" parent="1">
|
||||
<mxGeometry x="870" y="687" width="490" height="320" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-87" value="Email system" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-86">
|
||||
<mxGeometry width="490" height="320" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-113" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="6F2B19X3ZkVbRV3rCgbW-86" source="6F2B19X3ZkVbRV3rCgbW-109" target="6F2B19X3ZkVbRV3rCgbW-112">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-114" value="No" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-113">
|
||||
<mxGeometry x="-0.1453" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="2" y="-12" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-116" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="6F2B19X3ZkVbRV3rCgbW-86" source="6F2B19X3ZkVbRV3rCgbW-109" target="6F2B19X3ZkVbRV3rCgbW-115">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-117" value="Yes" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-116">
|
||||
<mxGeometry x="0.3626" y="3" relative="1" as="geometry">
|
||||
<mxPoint x="18" y="-2" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-109" value="Valid Email Claim?" style="rhombus;whiteSpace=wrap;html=1;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-86">
|
||||
<mxGeometry x="298.780487804878" y="225" width="95.60975609756098" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-112" value="Reject email" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#a0522d;fontColor=#ffffff;strokeColor=#6D1F00;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-86">
|
||||
<mxGeometry x="380" y="155.65" width="103.41" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-120" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="6F2B19X3ZkVbRV3rCgbW-86" source="6F2B19X3ZkVbRV3rCgbW-115" target="6F2B19X3ZkVbRV3rCgbW-121">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="89.6341463414634" y="201.30000000000007" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-125" value="Encrypt email contents <br>with symmetric key <div><br/></div>" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-120">
|
||||
<mxGeometry x="-0.4172" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="1" y="-1" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-133" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="6F2B19X3ZkVbRV3rCgbW-86" source="6F2B19X3ZkVbRV3rCgbW-115" target="6F2B19X3ZkVbRV3rCgbW-124">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-115" value="Random generated<br>symmetric key" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#e3c800;fontColor=#000000;strokeColor=#B09500;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-86">
|
||||
<mxGeometry x="239.9982926829268" y="145.65" width="131.46341463414635" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-121" value="AES256-GCM" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#76608a;fontColor=#ffffff;strokeColor=#432D57;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-86">
|
||||
<mxGeometry x="95.60975609756098" y="35.650000000000006" width="119.51219512195121" height="35.65" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-137" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.25;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="6F2B19X3ZkVbRV3rCgbW-86" source="6F2B19X3ZkVbRV3rCgbW-124" target="6F2B19X3ZkVbRV3rCgbW-121">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-124" value="Encrypt symmetric key with public key" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#a0522d;fontColor=#ffffff;strokeColor=#6D1F00;" vertex="1" parent="6F2B19X3ZkVbRV3rCgbW-86">
|
||||
<mxGeometry x="70.00121951219514" y="105.65" width="131.46341463414635" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-101" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=1;entryY=0.25;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-78" target="6F2B19X3ZkVbRV3rCgbW-99">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="892" y="292" />
|
||||
<mxPoint x="750" y="292" />
|
||||
<mxPoint x="750" y="882" />
|
||||
<mxPoint x="666" y="882" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-102" value="Save in server <br>public key store" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-101">
|
||||
<mxGeometry x="0.4464" y="-2" relative="1" as="geometry">
|
||||
<mxPoint x="9" y="-242" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-103" value="Register new email<br>claim" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#a0522d;fontColor=#ffffff;strokeColor=#6D1F00;" vertex="1" parent="1">
|
||||
<mxGeometry x="781" y="332" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-140" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.75;exitDx=0;exitDy=0;entryX=0.25;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-99" target="6F2B19X3ZkVbRV3rCgbW-124">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-141" value="Retrieve public key<div>associated with email claim</div>" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-140">
|
||||
<mxGeometry x="0.4692" relative="1" as="geometry">
|
||||
<mxPoint x="17" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-99" value="Public key store" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#1ba1e2;strokeColor=#006EAF;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="556" y="872.65" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-138" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-105" target="6F2B19X3ZkVbRV3rCgbW-109">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-153" value="Retrieve registered email <br>address claims" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-138">
|
||||
<mxGeometry x="0.2388" y="-5" relative="1" as="geometry">
|
||||
<mxPoint x="-10" y="-2" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-105" value="Email claim store" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#1ba1e2;strokeColor=#006EAF;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="555" y="932.65" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-158" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-107" target="6F2B19X3ZkVbRV3rCgbW-109">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-107" value="External email received from internet" style="shape=message;html=1;html=1;outlineConnect=0;labelPosition=center;verticalLabelPosition=bottom;align=center;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="1186.3899999999999" y="1058" width="60" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-106" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="830" y="372" as="sourcePoint" />
|
||||
<mxPoint x="665" y="942" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="830" y="372" />
|
||||
<mxPoint x="830" y="942" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-152" value="Register email claim<br>on server" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-106">
|
||||
<mxGeometry x="-0.3735" relative="1" as="geometry">
|
||||
<mxPoint x="11" y="-153" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-123" value="Encrypted email(s)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#1ba1e2;strokeColor=#006EAF;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="556" y="812.65" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-134" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-121" target="6F2B19X3ZkVbRV3rCgbW-123">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1025" y="703" />
|
||||
<mxPoint x="850" y="703" />
|
||||
<mxPoint x="850" y="843" />
|
||||
<mxPoint x="666" y="843" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-135" value="Store encrypted email" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-134">
|
||||
<mxGeometry x="0.6857" relative="1" as="geometry">
|
||||
<mxPoint x="-1" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-136" value="Email retrieval and decryption" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;" vertex="1" parent="1">
|
||||
<mxGeometry x="996.3900000000001" y="82" width="250" height="320" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-145" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-143" target="6F2B19X3ZkVbRV3rCgbW-144">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-146" value="Decrypt email using private key<br>&nbsp;stored in vault" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-145">
|
||||
<mxGeometry x="0.1105" y="2" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-143" value="Retrieve encrypted email" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#a0522d;fontColor=#ffffff;strokeColor=#6D1F00;" vertex="1" parent="1">
|
||||
<mxGeometry x="1061.39" y="122" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-148" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-144" target="6F2B19X3ZkVbRV3rCgbW-162">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1121.39" y="322" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-144" value="AES256-GCM" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#76608a;fontColor=#ffffff;strokeColor=#432D57;" vertex="1" parent="1">
|
||||
<mxGeometry x="1066.39" y="232" width="110" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-150" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.25;exitDx=0;exitDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-123">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1060" y="142" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="672" y="823" />
|
||||
<mxPoint x="920" y="822" />
|
||||
<mxPoint x="920" y="482" />
|
||||
<mxPoint x="1040" y="482" />
|
||||
<mxPoint x="1040" y="142" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-151" value="Retrieve encrypted email" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-150">
|
||||
<mxGeometry x="-0.6389" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="-117" y="-1" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-159" value="" style="shape=image;aspect=fixed;image=data:image/svg+xml,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MDAgNTAwIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCA1MDAgNTAwIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJtNDU5Ljg3IDI5NC45NWMwLjAxNjIwNSA1LjQwMDUgMC4wMzI0MSAxMC44MDEtMC4zNTAyMiAxNi44NzMtMS4xMTEgNi4zMzkyLTEuMTk0MSAxMi4xNzMtMi42MzUxIDE3LjY0OS0xMC45MjIgNDEuNTA4LTM2LjczMSA2OS40ODEtNzcuMzUxIDgzLjQwOC03LjIxNTcgMi40NzM5LTE0Ljk3MiAzLjM3MDItMjIuNDc5IDQuOTk1LTIzLjYyOSAwLjA0MjIwNS00Ny4yNTcgMC4xMTQ1My03MC44ODYgMC4xMjAyNy00Ni43NjIgMC4wMTEzMjItOTMuNTIzLTAuMDE0MTYtMTQwLjk1LTAuNDM0MTEtOC41OS0yLjAwMjQtMTYuNzY2LTIuODM1Mi0yNC4zOTgtNS4zMzI2LTIxLjU5NS03LjA2NjYtMzkuNTIzLTE5LjY1Ni01My43MDgtMzcuNTUyLTEwLjIyNy0xMi45MDMtMTcuNTc5LTI3LjE3LTIxLjI4LTQzLjIyMS0xLjQ3NS02LjM5NjctMi40NzExLTEyLjkwNC0zLjY4NTItMTkuMzYxLTAuMDUxODQ5LTUuNzQ3LTAuMTAzNy0xMS40OTQgMC4yNjkxNS0xNy44ODYgNC4xNTktNDIuOTczIDI3LjY4LTcxLjYzOCA2My41NjItOTIuMTUzIDAtMC43MDc2MS0wLjAwMTk2MS0xLjY5ODggMy4xMmUtNCAtMi42OSAwLjAyMjQ4NC05LjgyOTMtMS4zMDcxLTE5Ljg5NCAwLjM1NjY0LTI5LjQzOCAzLjIzOTEtMTguNTc5IDExLjA4LTM1LjI3MiAyMy43NjMtNDkuNzczIDEyLjA5OC0xMy44MzIgMjYuNDU3LTIzLjk4OSA0My42MDktMzAuMDI5IDcuODEzLTIuNzUxMiAxNi4xNC00LjA0MTcgMjQuMjM0LTUuOTk0OCA3LjM5Mi0wLjAyNTczNCAxNC43ODQtMC4wNTE0NiAyMi44MzUgMC4zMjI1MyA0LjE5NTkgMC45NTM5MiA3Ljc5NDYgMS4yNTM4IDExLjI1OCAyLjEwNTMgMTcuMTYgNC4yMTkyIDMyLjI4NyAxMi4xNzYgNDUuNDY5IDI0LjEwNCAyLjI1NTggMi4wNDExIDQuMzcyIDYuNjI0MSA5LjYyMSAzLjg2OCAxNi44MzktOC44NDE5IDM0LjcxOC0xMS41OTcgNTMuNjAzLTguNTk0IDE2Ljc5MSAyLjY2OTkgMzEuNjAyIDkuNDMwOCA0NC4yMzYgMjAuNjM2IDExLjUzMSAxMC4yMjcgMTkuODQgMjIuODQxIDI1LjM5MyAzNy4yMzYgNi4zNDM2IDE2LjQ0NSAxMC4zODkgMzMuMTYzIDYuMDc5OCA0OS4zODkgNy45NTg3IDguOTMyMSAxNS44MDcgMTYuNzA0IDIyLjQyMSAyNS40MTQgOS4xNjIgMTIuMDY1IDE1LjMzIDI1Ljc0NiAxOC4xNDQgNDAuNzc2IDAuOTcwNDYgNS4xODQ4IDEuOTExMSAxMC4zNzUgMi44NjU0IDE1LjU2M20tNzEuNTk3IDcxLjAxMmM1LjU2MTUtNS4yMjg0IDEyLjAwMi05Ljc5ODYgMTYuNTA4LTE1LjgxNyAxMC40NzQtMTMuOTkyIDE0LjMzMy0yOS45MTYgMTEuMjg4LTQ3LjQ0Ni0yLjI0OTYtMTIuOTUtOC4xOTczLTI0LjA3Ni0xNy4yNDMtMzMuMDYzLTEyLjc0Ni0xMi42NjMtMjguODY1LTE4LjYxNC00Ni43ODYtMTguNTY5LTY5LjkxMiAwLjE3NzEyLTEzOS44MiAwLjU2ODMxLTIwOS43NCAwLjk2MTc2LTE1LjkyMiAwLjA4OTU5OS0yOS4xNjggNy40MjA5LTM5LjY4NSAxOC4yOTYtMTQuNDUgMTQuOTQ0LTIwLjQwOCAzMy4zNDMtMTYuNjU1IDU0LjM2OCAyLjI3NjMgMTIuNzU0IDguMjE2NyAyMy43NDggMTcuMTU4IDMyLjY2IDEzLjI5OSAxMy4yNTUgMzAuMDk3IDE4LjY1MyA0OC43MjggMTguNjUxIDU5LjMyMS0wLjAwNTE4OCAxMTguNjQgMC4wNDIzNTggMTc3Ljk2LTAuMDQ2NjAxIDkuNTkxMi0wLjAxNDM3NCAxOS4xODEtMC44NjU4OCAyOC43NzMtMC44ODg1NSAxMC42NDktMC4wMjUxNDYgMTkuOTc4LTMuODI1IDI5LjY4Ny05LjEwNzR6IiBmaWxsPSIjRUVDMTcwIi8+CjxwYXRoIGQ9Im0xNjIuNzcgMjkzYzE1LjY1NCA0LjM4ODMgMjAuNjI3IDIyLjk2NyAxMC4zMDQgMzQuOTgtNS4zMTA0IDYuMTc5NS0xNC44MTcgOC4zMjA4LTI0LjI3OCA1LjA0NzItNy4wNzIzLTIuNDQ3MS0xMi4zMzItMTAuMzYyLTEyLjg3Ni0xNy45MzMtMS4wNDUxLTE0LjU0MiAxMS4wODktMjMuMTc2IDIxLjcwNS0yMy4wNDYgMS41Nzk0IDAuMDE5Mjg3IDMuMTUxNyAwLjYxNTY2IDUuMTQ2MSAwLjk1MTg0eiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMjI3LjE4IDI5My42NGM3Ljg0OTkgMi4zOTczIDExLjkzOCA4LjIxNDMgMTMuNTI0IDE1LjA3NyAxLjg1OTEgOC4wNDM5LTAuNDQ4MTcgMTUuNzA2LTcuMTU4OCAyMS4xMjEtNi43NjMzIDUuNDU3Mi0xNC40MTcgNi44Nzk0LTIyLjU3OCAzLjE0ODMtOC4yOTcyLTMuNzkzMy0xMi44MzYtMTAuODQ5LTEyLjczNi0xOS40MzggMC4xNjg3LTE0LjQ5NyAxNC4xMy0yNS4zNjggMjguOTQ4LTE5LjkwOHoiIGZpbGw9IiNFRUMxNzAiLz4KPHBhdGggZD0ibTI2MS41NyAzMTkuMDdjLTIuNDk1LTE0LjQxOCA0LjY4NTMtMjIuNjAzIDE0LjU5Ni0yNi4xMDggOS44OTQ1LTMuNDk5NSAyMy4xODEgMy40MzAzIDI2LjI2NyAxMy43NzkgNC42NTA0IDE1LjU5MS03LjE2NTEgMjkuMDY0LTIxLjY2NSAyOC4xNjEtOC41MjU0LTAuNTMwODgtMTcuMjAyLTYuNTA5NC0xOS4xOTgtMTUuODMxeiIgZmlsbD0iI0VFQzE3MCIvPgo8cGF0aCBkPSJtMzM2LjkxIDMzMy40MWMtOS4wMTc1LTQuMjQ5MS0xNS4zMzctMTQuMzQ5LTEzLjgyOS0yMS42ODIgMy4wODI1LTE0Ljk4OSAxMy4zNDEtMjAuMzA0IDIzLjAxOC0xOS41ODUgMTAuNjUzIDAuNzkxNDEgMTcuOTMgNy40MDcgMTkuNzY1IDE3LjU0NyAxLjk1ODggMTAuODI0LTQuMTE3MSAxOS45MzktMTMuNDk0IDIzLjcwMy01LjI3MiAyLjExNjItMTAuMDkxIDEuNTA4Ni0xNS40NiAwLjAxNzg4M3oiIGZpbGw9IiNFRUMxNzAiLz4KPC9zdmc+Cg==;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="1060" width="58" height="58" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-160" value="AliasVault" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=24;fontStyle=1" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="1064" width="130" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-161" value="Security architecture 0.6.0" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="1">
|
||||
<mxGeometry x="98" y="1088" width="160" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-162" value="Decrypted email" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#e3c800;fontColor=#000000;strokeColor=#B09500;" vertex="1" parent="1">
|
||||
<mxGeometry x="1056.39" y="320" width="130" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-58" value="JWT token for <br>authenticating<br>with REST API" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#e3c800;fontColor=#000000;strokeColor=#B09500;" vertex="1" parent="1">
|
||||
<mxGeometry x="364" y="559" width="155" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-165" value="Client local storage" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;container=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="370" y="82" width="140" height="320" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-166" value="Decrypted vault<br>(app memory)" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;fillColor=#1ba1e2;fontColor=#ffffff;strokeColor=#006EAF;container=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="380" y="112" width="110" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-167" value="JWT Token<br>(browser local storage)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#1ba1e2;strokeColor=#006EAF;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="383" y="312" width="116" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-169" value="Derived Key<br>(app<span style="background-color: initial;">&nbsp;memory)</span>" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#1ba1e2;strokeColor=#006EAF;fontColor=#ffffff;" vertex="1" parent="1">
|
||||
<mxGeometry x="383" y="242" width="116" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-84" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-79" target="6F2B19X3ZkVbRV3rCgbW-166">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="630" y="222" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="752" y="152" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-85" value="Stored in vault" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-84">
|
||||
<mxGeometry x="0.3345" y="3" relative="1" as="geometry">
|
||||
<mxPoint x="7" y="-3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-170" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" target="6F2B19X3ZkVbRV3rCgbW-169">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="280" y="342" as="sourcePoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="320" y="342" />
|
||||
<mxPoint x="320" y="267" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-173" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-58" target="6F2B19X3ZkVbRV3rCgbW-167">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-174" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-39" target="6F2B19X3ZkVbRV3rCgbW-58">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="500" y="942" />
|
||||
<mxPoint x="500" y="632" />
|
||||
<mxPoint x="442" y="632" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-176" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-175" target="6F2B19X3ZkVbRV3rCgbW-16">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-175" value="User login" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#a0522d;fontColor=#ffffff;strokeColor=#6D1F00;" vertex="1" parent="1">
|
||||
<mxGeometry x="162" y="112" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-178" value="<span style="">JWT token protected API endpoints</span>" style="shape=corner;whiteSpace=wrap;html=1;fontColor=default;fillColor=#e3c800;strokeColor=#B09500;" vertex="1" parent="1">
|
||||
<mxGeometry x="567" y="552" width="803" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-179" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="6F2B19X3ZkVbRV3rCgbW-27" target="6F2B19X3ZkVbRV3rCgbW-166">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="609" y="212" />
|
||||
<mxPoint x="435" y="212" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6F2B19X3ZkVbRV3rCgbW-182" value="Store vault <br>in local WASM memory" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="6F2B19X3ZkVbRV3rCgbW-179">
|
||||
<mxGeometry x="-0.4412" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="28" y="-21" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
File diff suppressed because one or more lines are too long
118
docs/diagrams/security-architecture/security-architecture.mmd
Normal file
118
docs/diagrams/security-architecture/security-architecture.mmd
Normal file
@@ -0,0 +1,118 @@
|
||||
%%{ init: { 'flowchart': { 'curve': 'basis' } } }%%
|
||||
graph TB
|
||||
%% Legend
|
||||
subgraph Legend["Legend"]
|
||||
L_CRYPTO["Cryptographic Operations"]
|
||||
L_STORAGE["Storage Elements"]
|
||||
L_KEY["Keys and Sensitive Data"]
|
||||
L_PROCESS["Process Steps"]
|
||||
L_AUTH["Authentication Steps"]
|
||||
L_FLOW["Email Processing Flow"]
|
||||
end
|
||||
|
||||
subgraph Client["Client (Local Only Operations)"]
|
||||
direction TB
|
||||
MP[/"Master Password\n(never leaves client)"/]
|
||||
|
||||
subgraph KD["1. Key Derivation"]
|
||||
A2["Argon2id"]
|
||||
DK[/"Derived Key"/]
|
||||
MP --> A2
|
||||
A2 --> DK
|
||||
DK --> |"used for vault\nencryption/decryption"| AES
|
||||
DK --> |"used for authentication"| SRP_C
|
||||
end
|
||||
|
||||
subgraph VE["3. Vault Operations"]
|
||||
AES["AES256-GCM"]
|
||||
VAULT["Encrypted Vault Contents"]
|
||||
AES --> |"encrypt/decrypt"| VAULT
|
||||
end
|
||||
|
||||
subgraph KP["4. Email Key Management"]
|
||||
RSA["RSA/OAEP Key Generation"]
|
||||
PRK[/"Private Key\n(stored in vault)"/]
|
||||
PBK[/"Public Key"/]
|
||||
RSA --> |"generates pair"| PRK
|
||||
RSA --> |"generates pair"| PBK
|
||||
end
|
||||
|
||||
subgraph ED["5. Email Decryption"]
|
||||
PRK --> |"decrypt symmetric key"| SK[/"Symmetric Key\n(AES256)"/]
|
||||
SK --> |"decrypt email"| DC["Decrypted Email"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph Server["Server"]
|
||||
direction TB
|
||||
|
||||
subgraph AUTH["Authentication Flow"]
|
||||
SRP_S["SRP Verification"]
|
||||
subgraph FA["2FA (Optional)"]
|
||||
TOTP["Time-based OTP"]
|
||||
GA["Google Authenticator\nor Compatible App"]
|
||||
VERIFY["Verify OTP Code"]
|
||||
GA --> |"generate code"| TOTP
|
||||
TOTP --> |"user enters"| VERIFY
|
||||
end
|
||||
JWT["Issue JWT Token"]
|
||||
SRP_S --> FA
|
||||
FA --> |"if 2FA enabled"| JWT
|
||||
SRP_S --> |"if 2FA disabled"| JWT
|
||||
end
|
||||
|
||||
subgraph VS["Vault Storage"]
|
||||
EV["Encrypted Vault Data"]
|
||||
end
|
||||
|
||||
subgraph ES["Email System"]
|
||||
EC["Email Claims"]
|
||||
PKS["Public Key Store"]
|
||||
|
||||
subgraph EP["Email Processing"]
|
||||
CHECK{"Valid\nEmail Claim?"}
|
||||
REJECT["Reject Email"]
|
||||
ESK[/"Generate Random\nSymmetric Key"/]
|
||||
EE["Encrypt Email\nContent"]
|
||||
ESP["Encrypt Symmetric Key\nwith Public Key"]
|
||||
EST["Store Encrypted Email\n& Encrypted Sym Key"]
|
||||
|
||||
CHECK --> |"No"| REJECT
|
||||
CHECK --> |"Yes"| ESK
|
||||
ESK --> EE
|
||||
ESK --> ESP
|
||||
EE --> EST
|
||||
ESP --> EST
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
%% Client-Server Interactions
|
||||
SRP_C["SRP Client"] <--> |"SRP Authentication"| SRP_S
|
||||
AES <--> |"encrypted vault transfer"| EV
|
||||
PBK --> |"register"| PKS
|
||||
EST --> |"retrieve encrypted\nemail & sym key"| ED
|
||||
|
||||
%% Styling
|
||||
classDef process fill:#ddd,stroke:#333,stroke-width:2px
|
||||
classDef storage fill:#b7e3fc,stroke:#333,stroke-width:2px
|
||||
classDef key fill:#fef08a,stroke:#333,stroke-width:2px
|
||||
classDef crypto fill:#e9d5ff,stroke:#333,stroke-width:2px
|
||||
classDef auth fill:#86efac,stroke:#333,stroke-width:2px
|
||||
classDef flow fill:#fca5a5,stroke:#333,stroke-width:2px
|
||||
|
||||
%% Apply styles to legend
|
||||
class L_CRYPTO crypto
|
||||
class L_STORAGE storage
|
||||
class L_KEY key
|
||||
class L_PROCESS process
|
||||
class L_AUTH auth
|
||||
class L_FLOW flow
|
||||
|
||||
%% Apply styles to elements
|
||||
class A2,SRP_C,SRP_S,RSA,AES crypto
|
||||
class EV,EST storage
|
||||
class MP,DK,PRK,PBK,SK,ESK key
|
||||
class KD,VE,KP,ED process
|
||||
class SRP_S,FA,JWT,TOTP,VERIFY auth
|
||||
class CHECK,EP flow
|
||||
BIN
docs/img/screenshot.png
Normal file
BIN
docs/img/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
75
docs/security-architecture.md
Normal file
75
docs/security-architecture.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Security Architecture
|
||||
|
||||
AliasVault implements a zero-knowledge architecture where sensitive user data and passwords never leave the client device in unencrypted form. Below is a detailed explanation of how the system secures user data and communications.
|
||||
|
||||
## Diagram
|
||||
The security architecture diagram below illustrates all encryption and authentication processes used in AliasVault to secure user data and communications.
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="diagrams/security-architecture/aliasvault-security-architecture-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="diagrams/security-architecture/aliasvault-security-architecture-light.svg">
|
||||
<img alt="AliasVault Security Architecture Diagram" src="diagrams/security-architecture/aliasvault-security-architecture-light.svg">
|
||||
</picture>
|
||||
|
||||
You can also view the diagram in a browser-friendly HTML format: [AliasVault Security Architecture](diagrams/security-architecture/aliasvault-security-architecture.html)
|
||||
|
||||
## Key Components and Process Flow
|
||||
|
||||
### 1. Key Derivation
|
||||
- When a user enters their master password, it remains strictly on the client device
|
||||
- The master password is processed through Argon2id (a memory-hard key derivation function) locally
|
||||
- The derived key serves two purposes:
|
||||
- Authentication with the server through the SRP protocol
|
||||
- Local encryption/decryption of vault contents using AES-256-GCM
|
||||
|
||||
### 2. Authentication Process
|
||||
1. SRP (Secure Remote Password) Authentication
|
||||
- Enables secure password-based authentication without transmitting the password
|
||||
- Client and server perform a cryptographic handshake to verify identity
|
||||
|
||||
2. Two-Factor Authentication (Optional)
|
||||
- If enabled, requires an additional verification step after successful SRP authentication
|
||||
- Uses Time-based One-Time Password (TOTP) protocol
|
||||
- Compatible with standard authenticator apps (e.g., Google Authenticator)
|
||||
- Server only issues the final JWT access token after successful 2FA verification
|
||||
|
||||
### 3. Vault Operations
|
||||
- All vault contents are encrypted/decrypted locally using AES-256-GCM
|
||||
- The encryption key is derived from the user's master password
|
||||
- Only encrypted data is ever transmitted to or stored on the server
|
||||
- The server never has access to the unencrypted vault contents
|
||||
|
||||
### 4. Email System Security
|
||||
|
||||
#### Key Generation and Storage
|
||||
1. RSA key pair is generated locally on the client
|
||||
2. Private key is stored in the encrypted vault
|
||||
3. Public key is sent to the server and associated with email claim(s)
|
||||
|
||||
#### Email Reception Process
|
||||
1. When an email is received, the server:
|
||||
- Verifies if the recipient has a valid email claim
|
||||
- If no valid claim exists, the email is rejected
|
||||
- If valid, generates a random 256-bit symmetric key
|
||||
- Encrypts the email content using this symmetric key
|
||||
- Encrypts the symmetric key using the recipient's public key
|
||||
- Stores both the encrypted email and encrypted symmetric key
|
||||
|
||||
#### Email Retrieval Process
|
||||
1. Client retrieves encrypted email and encrypted symmetric key from server
|
||||
2. Client uses private key from vault to decrypt the symmetric key
|
||||
3. Client uses decrypted symmetric key to decrypt the email contents
|
||||
4. All decryption occurs locally on the client device
|
||||
|
||||
> Note: The use of a symmetric key for email content encryption and asymmetric encryption for the symmetric key (hybrid encryption) is implemented due to RSA's limitations on encryption string length and for better performance.
|
||||
|
||||
## Security Benefits
|
||||
- Zero-knowledge architecture ensures user data privacy
|
||||
- Master password never leaves the client device
|
||||
- All sensitive operations (key derivation, encryption/decryption) happen locally
|
||||
- Server stores only encrypted data
|
||||
- Multi-layer encryption for emails provides secure communication
|
||||
- Optional 2FA adds an additional security layer
|
||||
- Use of established cryptographic standards (Argon2id, AES-256-GCM, RSA/OAEP)
|
||||
|
||||
This security architecture ensures that even if the server is compromised, user data remains secure as all sensitive operations and keys remain strictly on the client side.
|
||||
51
install.sh
51
install.sh
@@ -51,11 +51,11 @@ generate_admin_password() {
|
||||
|
||||
if [ "$VERBOSE" = true ]; then
|
||||
printf "\n"
|
||||
docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile .
|
||||
docker build -t initcli -f src/Utilities/AliasVault.InstallCli/Dockerfile .
|
||||
else
|
||||
(
|
||||
# Run docker build and capture its output
|
||||
docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile . > install_build_output.log 2>&1 &
|
||||
docker build -t initcli -f src/Utilities/AliasVault.InstallCli/Dockerfile . > install_build_output.log 2>&1 &
|
||||
BUILD_PID=$!
|
||||
|
||||
printf "${CYAN}"
|
||||
@@ -119,6 +119,11 @@ generate_jwt_key() {
|
||||
dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | head -c 32
|
||||
}
|
||||
|
||||
# Function to generate a new 60-character DATA_PROTECTION_CERT_PASS
|
||||
generate_data_protection_cert_pass() {
|
||||
dd if=/dev/urandom bs=1 count=60 2>/dev/null | base64 | head -c 60
|
||||
}
|
||||
|
||||
# Function to create .env file from .env.example if it doesn't exist
|
||||
create_env_file() {
|
||||
printf "${CYAN}> Creating .env file...${NC}\n"
|
||||
@@ -170,6 +175,22 @@ populate_jwt_key() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check and populate the .env file with DATA_PROTECTION_CERT_PASS
|
||||
populate_data_protection_cert_pass() {
|
||||
printf "${CYAN}> Checking DATA_PROTECTION_CERT_PASS...${NC}\n"
|
||||
if ! grep -q "^DATA_PROTECTION_CERT_PASS=" "$ENV_FILE" || [ -z "$(grep "^DATA_PROTECTION_CERT_PASS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
|
||||
DATA_PROTECTION_CERT_PASS=$(generate_data_protection_cert_pass)
|
||||
if grep -q "^DATA_PROTECTION_CERT_PASS=" "$ENV_FILE"; then
|
||||
awk -v key="DATA_PROTECTION_CERT_PASS" '/^DATA_PROTECTION_CERT_PASS=/ {$0="DATA_PROTECTION_CERT_PASS="key} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
|
||||
else
|
||||
echo "DATA_PROTECTION_CERT_PASS=${DATA_PROTECTION_CERT_PASS}" >> "$ENV_FILE"
|
||||
fi
|
||||
printf "${GREEN}> DATA_PROTECTION_CERT_PASS has been generated and added to $ENV_FILE.${NC}\n"
|
||||
else
|
||||
printf "${GREEN}> DATA_PROTECTION_CERT_PASS already exists and has a value in $ENV_FILE.${NC}\n"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to ask the user for PRIVATE_EMAIL_DOMAINS
|
||||
set_private_email_domains() {
|
||||
printf "${CYAN}> Setting PRIVATE_EMAIL_DOMAINS...${NC}\n"
|
||||
@@ -224,6 +245,30 @@ set_smtp_tls_enabled() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to ask for support email
|
||||
set_support_email() {
|
||||
printf "${CYAN}> Setting SUPPORT_EMAIL...${NC}\n"
|
||||
if ! grep -q "^SUPPORT_EMAIL=" "$ENV_FILE"; then
|
||||
printf "Please enter the support email address that users can contact for issues accessing their vault (press Enter to disable): "
|
||||
read -r support_email
|
||||
|
||||
echo "SUPPORT_EMAIL=${support_email}" >> "$ENV_FILE"
|
||||
|
||||
if [ -z "$support_email" ]; then
|
||||
printf "${GREEN}> SUPPORT_EMAIL has been left empty in $ENV_FILE.${NC}\n"
|
||||
else
|
||||
printf "${GREEN}> SUPPORT_EMAIL has been set to '${support_email}' in $ENV_FILE.${NC}\n"
|
||||
fi
|
||||
else
|
||||
support_email=$(grep "^SUPPORT_EMAIL=" "$ENV_FILE" | cut -d '=' -f2)
|
||||
if [ -z "$support_email" ]; then
|
||||
printf "${GREEN}> SUPPORT_EMAIL already exists in $ENV_FILE but is empty.${NC}\n"
|
||||
else
|
||||
printf "${GREEN}> SUPPORT_EMAIL already exists in $ENV_FILE with value: ${support_email}${NC}\n"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to build and run the Docker Compose stack with muted output unless an error occurs, showing progress indication
|
||||
build_and_run_docker_compose() {
|
||||
printf "${CYAN}> Building Docker Compose stack..."
|
||||
@@ -316,8 +361,10 @@ main() {
|
||||
create_env_file || exit $?
|
||||
populate_api_url || exit $?
|
||||
populate_jwt_key || exit $?
|
||||
populate_data_protection_cert_pass || exit $?
|
||||
set_private_email_domains || exit $?
|
||||
set_smtp_tls_enabled || exit $?
|
||||
set_support_email || exit $?
|
||||
generate_admin_password || exit $?
|
||||
printf "\n${YELLOW}+++ Building Docker containers +++${NC}\n"
|
||||
printf "\n"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="FigIdentityGenerator.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 AliasGenerators.Identity.Implementations;
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Identity generator which generates random identities using the identiteitgenerator.nl semi-public API.
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
var response = await HttpClient.GetAsync(Url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var identity = JsonSerializer.Deserialize<Identity.Models.Identity>(json, JsonSerializerOptions);
|
||||
|
||||
if (identity is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to deserialize the identity from FIG WebApi.");
|
||||
}
|
||||
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="StaticIdentityGenerator.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 AliasGenerators.Identity.Implementations;
|
||||
|
||||
using AliasGenerators.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Static identity generator which implements IIdentityGenerator but always returns
|
||||
/// the same static identity for testing purposes.
|
||||
/// </summary>
|
||||
public class StaticIdentityGenerator : IIdentityGenerator
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public async Task<Identity.Models.Identity> GenerateRandomIdentityAsync()
|
||||
{
|
||||
await Task.Yield(); // Add an await statement to make the method truly asynchronous.
|
||||
return new Identity.Models.Identity
|
||||
{
|
||||
FirstName = "John",
|
||||
LastName = "Doe",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="Address.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 AliasGenerators.Identity.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Address model.
|
||||
/// </summary>
|
||||
public class Address
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the street.
|
||||
/// </summary>
|
||||
public string Street { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the city.
|
||||
/// </summary>
|
||||
public string City { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the state.
|
||||
/// </summary>
|
||||
public string State { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the zip code.
|
||||
/// </summary>
|
||||
public string ZipCode { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the country.
|
||||
/// </summary>
|
||||
public string Country { get; set; } = null!;
|
||||
}
|
||||
@@ -1,88 +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 AliasGenerators.Identity.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Identity model.
|
||||
/// </summary>
|
||||
public class Identity
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the id.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the gender.
|
||||
/// </summary>
|
||||
public int Gender { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the first name.
|
||||
/// </summary>
|
||||
public string FirstName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last name.
|
||||
/// </summary>
|
||||
public string LastName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the nickname. This is also used as the username.
|
||||
/// </summary>
|
||||
public string NickName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the birth date.
|
||||
/// </summary>
|
||||
public DateTime BirthDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the address.
|
||||
/// </summary>
|
||||
public Address Address { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the job.
|
||||
/// </summary>
|
||||
public Job Job { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hobbies.
|
||||
/// </summary>
|
||||
public List<string> Hobbies { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the email address prefix.
|
||||
/// </summary>
|
||||
public string EmailPrefix { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the password.
|
||||
/// </summary>
|
||||
public string Password { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the phone mobile.
|
||||
/// </summary>
|
||||
public string PhoneMobile { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the bank account IBAN.
|
||||
/// </summary>
|
||||
public string BankAccountIBAN { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the profile photo in base64 format.
|
||||
/// </summary>
|
||||
public string ProfilePhotoBase64 { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the profile photo prompt.
|
||||
/// </summary>
|
||||
public string ProfilePhotoPrompt { get; set; } = null!;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="Job.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 AliasGenerators.Identity.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Job model.
|
||||
/// </summary>
|
||||
public class Job
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the title.
|
||||
/// </summary>
|
||||
public string Title { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the company.
|
||||
/// </summary>
|
||||
public string Company { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the salary.
|
||||
/// </summary>
|
||||
public string Salary { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the calculated salary.
|
||||
/// </summary>
|
||||
public decimal SalaryCalculated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the description.
|
||||
/// </summary>
|
||||
public string Description { get; set; } = null!;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>aspnet-AliasVault.Admin-1DAADE35-C01B-43BB-B440-AA5E1E0B672D</UserSecretsId>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<NoWarn>1701;1702;NU1900</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
@@ -19,10 +20,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.7">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.10">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -44,8 +43,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
|
||||
<ProjectReference Include="..\Shared\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj" />
|
||||
<ProjectReference Include="..\Shared\AliasVault.Shared.Core\AliasVault.Shared.Core.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.Auth\AliasVault.Auth.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj" />
|
||||
<ProjectReference Include="..\Utilities\Cryptography\AliasVault.Cryptography.Server\AliasVault.Cryptography.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -12,30 +12,36 @@
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the input field.
|
||||
/// </summary>
|
||||
[Parameter] public string Id { get; set; } = null!;
|
||||
[Parameter]
|
||||
public required string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the value of the input field.
|
||||
/// </summary>
|
||||
[Parameter] public string Value { get; set; } = null!;
|
||||
[Parameter]
|
||||
public required string Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the event callback that is triggered when the value changes.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
|
||||
[Parameter]
|
||||
public required EventCallback<string?> ValueChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the expression that identifies the value property.
|
||||
/// </summary>
|
||||
[Parameter] public Expression<Func<string>> ValueExpression { get; set; } = null!;
|
||||
[Parameter]
|
||||
public required Expression<Func<string>> ValueExpression { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the placeholder text for the input field.
|
||||
/// </summary>
|
||||
[Parameter] public string Placeholder { get; set; } = null!;
|
||||
[Parameter]
|
||||
public required string Placeholder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional attributes for the input field.
|
||||
/// </summary>
|
||||
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object?>? AdditionalAttributes { get; set; } = new();
|
||||
[Parameter(CaptureUnmatchedValues = true)]
|
||||
public Dictionary<string, object?>? AdditionalAttributes { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<a href="/" class="flex items-center justify-center mb-8 text-2xl font-semibold lg:mb-10 dark:text-white">
|
||||
<img src="horizontal-logo-cropped.png" alt="AliasVault" class="img-fluid" style="max-width: 330px;"/>
|
||||
<span class="ps-2 self-center hidden sm:flex text-lg font-bold whitespace-nowrap text-white bg-red-600 rounded-full px-2 py-1 ml-2">Admin</span>
|
||||
|
||||
<a href="/">
|
||||
<div class="text-5xl font-bold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<img src="img/logo.svg" alt="AliasVault" class="w-20 h-20 mr-2" />
|
||||
<span>AliasVault</span>
|
||||
<span class="ps-2 self-center hidden sm:flex text-lg font-bold whitespace-nowrap text-white bg-red-600 rounded-full px-2 py-1 ml-2">Admin</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace AliasVault.Admin.Auth.Pages;
|
||||
using AliasServerDb;
|
||||
using AliasVault.Admin.Main.Components.Alerts;
|
||||
using AliasVault.Admin.Services;
|
||||
using AliasVault.Auth;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -51,6 +52,12 @@ public class AuthBase : OwningComponentBase
|
||||
[Inject]
|
||||
protected AuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the auth logging service.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected AuthLoggingService AuthLoggingService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets object which holds server validation errors to show in the UI.
|
||||
/// </summary>
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Forgot your password?
|
||||
</h2>
|
||||
<p>If you have forgotten your password, please consult with the server admin.</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">If you have forgotten your password, contact the server admin or consult the AliasVault documentation on how to reset your password.</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<LayoutPageTitle>Locked out</LayoutPageTitle>
|
||||
|
||||
<header>
|
||||
<h1 class="text-danger">Locked out</h1>
|
||||
<p class="text-danger">This account has been locked out, please try again later.</p>
|
||||
</header>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Locked out
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">You have entered an incorrect password too many times and your account has now been locked out. You can try again in 30 minutes.</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@page "/user/login"
|
||||
@using AliasVault.Shared.Models.Enums
|
||||
|
||||
<LayoutPageTitle>Log in</LayoutPageTitle>
|
||||
|
||||
@@ -60,25 +61,38 @@
|
||||
{
|
||||
ServerValidationErrors.Clear();
|
||||
|
||||
var user = await UserManager.FindByNameAsync(Input.UserName);
|
||||
if (user == null)
|
||||
{
|
||||
|
||||
await AuthLoggingService.LogAuthEventFailAsync(Input.UserName, AuthEventType.Login, AuthFailureReason.InvalidUsername);
|
||||
ServerValidationErrors.AddError("Error: Invalid login attempt.");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await SignInManager.PasswordSignInAsync(Input.UserName, Input.Password, Input.RememberMe, lockoutOnFailure: true);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(Input.UserName, AuthEventType.Login);
|
||||
Logger.LogInformation("User logged in.");
|
||||
NavigationService.RedirectTo(ReturnUrl ?? "/");
|
||||
}
|
||||
else if (result.RequiresTwoFactor)
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(Input.UserName, AuthEventType.Login);
|
||||
NavigationService.RedirectTo(
|
||||
"user/loginWith2fa",
|
||||
new Dictionary<string, object?> { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventFailAsync(Input.UserName, AuthEventType.Login, AuthFailureReason.AccountLocked);
|
||||
Logger.LogWarning("User account locked out.");
|
||||
NavigationService.RedirectTo("user/lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventFailAsync(Input.UserName, AuthEventType.Login, AuthFailureReason.InvalidPassword);
|
||||
ServerValidationErrors.AddError("Error: Invalid login attempt.");
|
||||
}
|
||||
}
|
||||
@@ -91,7 +105,8 @@
|
||||
[DataType(DataType.Password)]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[Display(Name = "Remember me?")] public bool RememberMe { get; set; }
|
||||
[Display(Name = "Remember me?")]
|
||||
public bool RememberMe { get; set; } = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@page "/user/loginWith2fa"
|
||||
@using AliasVault.Shared.Models.Enums
|
||||
|
||||
<LayoutPageTitle>Two-factor authentication</LayoutPageTitle>
|
||||
|
||||
@@ -39,11 +40,14 @@
|
||||
@code {
|
||||
private AdminUser user = default!;
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery] private bool RememberMe { get; set; }
|
||||
[SupplyParameterFromQuery]
|
||||
private bool RememberMe { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -68,16 +72,19 @@
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.TwoFactorAuthentication);
|
||||
Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId);
|
||||
NavigationService.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TwoFactorAuthentication, AuthFailureReason.AccountLocked);
|
||||
Logger.LogWarning("User with ID '{UserId}' account locked out.", userId);
|
||||
NavigationService.RedirectTo("user/lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TwoFactorAuthentication, AuthFailureReason.InvalidTwoFactorCode);
|
||||
Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId);
|
||||
ServerValidationErrors.AddError("Error: Invalid authenticator code.");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@page "/user/loginWithRecoveryCode"
|
||||
@using AliasVault.Shared.Models.Enums
|
||||
|
||||
<LayoutPageTitle>Recovery code verification</LayoutPageTitle>
|
||||
|
||||
@@ -9,8 +10,8 @@
|
||||
<ServerValidationErrors @ref="ServerValidationErrors" />
|
||||
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-6">
|
||||
You have requested to log in with a recovery code. This login will not be remembered until you provide
|
||||
an authenticator app code at log in or disable 2FA and log in again.
|
||||
You have requested to log in with a recovery code. A recovery code is a one-time code that can be used to log in to your account.
|
||||
Note that if you don't manually disable 2FA after login, you will be asked for an authenticator code again at the next login.
|
||||
</p>
|
||||
<div class="w-full max-w-md">
|
||||
<EditForm Model="Input" FormName="login-with-recovery-code" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
|
||||
@@ -48,23 +49,24 @@
|
||||
ServerValidationErrors.Clear();
|
||||
|
||||
var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
|
||||
|
||||
var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.TwoFactorAuthentication);
|
||||
Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId);
|
||||
NavigationService.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TwoFactorAuthentication, AuthFailureReason.AccountLocked);
|
||||
Logger.LogWarning("User account locked out.");
|
||||
NavigationService.RedirectTo("user/lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TwoFactorAuthentication, AuthFailureReason.InvalidRecoveryCode);
|
||||
Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId);
|
||||
ServerValidationErrors.AddError("Error: Invalid recovery code entered.");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
@page "/user/logout"
|
||||
@using AliasVault.Shared.Models.Enums
|
||||
@inject UserService UserService
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
|
||||
@code {
|
||||
@@ -10,10 +12,12 @@
|
||||
// the server session is already started.
|
||||
try
|
||||
{
|
||||
var username = UserService.User().UserName;
|
||||
try
|
||||
{
|
||||
await SignInManager.SignOutAsync();
|
||||
GlobalNotificationService.ClearMessages();
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(username!, AuthEventType.Logout);
|
||||
|
||||
// Redirect to the home page with hard refresh.
|
||||
NavigationService.RedirectTo("/", true);
|
||||
@@ -23,6 +27,7 @@
|
||||
// Hard refresh current page if sign out fails. When an interactive server session is already started
|
||||
// the sign out will fail because it tries to mutate cookies which is only possible when the server
|
||||
// session is not started yet.
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(username!, AuthEventType.Logout);
|
||||
NavigationService.RedirectTo(NavigationService.Uri, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,7 @@ WORKDIR /src
|
||||
|
||||
# Copy the project files and restore dependencies
|
||||
COPY ["src/AliasVault.Admin/AliasVault.Admin.csproj", "src/AliasVault.Admin/"]
|
||||
COPY ["src/Databases/AliasServerDb/AliasServerDb.csproj", "src/Databases/AliasServerDb/"]
|
||||
RUN dotnet restore "src/AliasVault.Admin/AliasVault.Admin.csproj"
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Build the WebApi project
|
||||
|
||||
@@ -2,67 +2,24 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<base href="/"/>
|
||||
<link rel="stylesheet" href="@VersionService.GetVersionedPath("css/tailwind.css")"/>
|
||||
<link rel="stylesheet" href="@VersionService.GetVersionedPath("css/app.css")"/>
|
||||
<link rel="stylesheet" href="AliasVault.Admin.styles.css"/>
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
<HeadOutlet @rendermode="RenderModeForPage"/>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 dark:bg-gray-800">
|
||||
<Routes @rendermode="RenderModeForPage"/>
|
||||
<script src="@VersionService.GetVersionedPath("lib/qrcode.min.js")"></script>
|
||||
<script src="@VersionService.GetVersionedPath("js/dark-mode.js")"></script>
|
||||
<script src="@VersionService.GetVersionedPath("js/utilities.js")"></script>
|
||||
|
||||
<script>
|
||||
window.initTopMenu = function() {
|
||||
initDarkModeSwitcher();
|
||||
};
|
||||
|
||||
window.registerClickOutsideHandler = (dotNetHelper) => {
|
||||
document.addEventListener('click', (event) => {
|
||||
const menu = document.getElementById('userMenuDropdown');
|
||||
const menuButton = document.getElementById('userMenuDropdownButton');
|
||||
if (menu && !menu.contains(event.target) && !menuButton.contains(event.target)) {
|
||||
dotNetHelper.invokeMethodAsync('CloseMenu');
|
||||
}
|
||||
|
||||
const mobileMenu = document.getElementById('mobileMenu');
|
||||
const mobileMenuButton = document.getElementById('toggleMobileMenuButton');
|
||||
if (mobileMenu && !mobileMenu.contains(event.target) && !mobileMenuButton.contains(event.target)) {
|
||||
dotNetHelper.invokeMethodAsync('CloseMenu');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.clipboardCopy = {
|
||||
copyText: function (text) {
|
||||
navigator.clipboard.writeText(text).then(function () { })
|
||||
.catch(function (error) {
|
||||
alert(error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.isFunctionDefined = function(functionName) {
|
||||
return typeof window[functionName] === 'function';
|
||||
};
|
||||
|
||||
// Primarily used by E2E tests.
|
||||
window.blazorNavigate = (url) => {
|
||||
Blazor.navigateTo(url);
|
||||
};
|
||||
</script>
|
||||
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||||
<base href="/"/>
|
||||
<link rel="stylesheet" href="@VersionService.GetVersionedPath("css/tailwind.css")"/>
|
||||
<link rel="stylesheet" href="@VersionService.GetVersionedPath("css/app.css")"/>
|
||||
<link rel="stylesheet" href="AliasVault.Admin.styles.css"/>
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
<HeadOutlet @rendermode="RenderModeForPage"/>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 dark:bg-gray-900">
|
||||
<Routes @rendermode="RenderModeForPage"/>
|
||||
<script src="@VersionService.GetVersionedPath("lib/qrcode.min.js")"></script>
|
||||
<script src="@VersionService.GetVersionedPath("js/dark-mode.js")"></script>
|
||||
<script src="@VersionService.GetVersionedPath("js/utilities.js")"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@foreach (var message in Messages)
|
||||
@if (Messages.Count == 0)
|
||||
{
|
||||
if (message.Key == "success")
|
||||
{
|
||||
<AlertMessageSuccess Message="@message.Value" />
|
||||
}
|
||||
return;
|
||||
}
|
||||
@foreach (var message in Messages)
|
||||
{
|
||||
if (message.Key == "error")
|
||||
|
||||
<div class="messages-container grid px-4 pt-6 lg:gap-4 dark:bg-gray-900">
|
||||
@foreach (var message in Messages)
|
||||
{
|
||||
<AlertMessageError Message="@message.Value" />
|
||||
if (message.Key == "success")
|
||||
{
|
||||
<AlertMessageSuccess Message="@message.Value" />
|
||||
}
|
||||
}
|
||||
}
|
||||
@foreach (var message in Messages)
|
||||
{
|
||||
if (message.Key == "info")
|
||||
{
|
||||
<AlertMessageInfo Message="@message.Value" />
|
||||
}
|
||||
}
|
||||
@foreach (var message in Messages)
|
||||
{
|
||||
if (message.Key == "warning")
|
||||
{
|
||||
<AlertMessageWarning Message="@message.Value" />
|
||||
}
|
||||
}
|
||||
@foreach (var message in Messages)
|
||||
{
|
||||
if (message.Key == "error")
|
||||
{
|
||||
<AlertMessageError Message="@message.Value" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.messages-container > :last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private List<KeyValuePair<string, string>> Messages { get; set; } = new();
|
||||
private bool _onChangeSubscribed = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
@@ -26,22 +53,26 @@
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
// We subscribe to the OnChange event of the PortalMessageService to update the UI when a new message is added
|
||||
RefreshAddMessages();
|
||||
GlobalNotificationService.OnChange += RefreshAddMessages;
|
||||
_onChangeSubscribed = true;
|
||||
NavigationManager.LocationChanged += HandleLocationChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// We unsubscribe from the OnChange event of the PortalMessageService when the component is disposed
|
||||
if (_onChangeSubscribed)
|
||||
{
|
||||
GlobalNotificationService.OnChange -= RefreshAddMessages;
|
||||
_onChangeSubscribed = false;
|
||||
}
|
||||
GlobalNotificationService.OnChange -= RefreshAddMessages;
|
||||
NavigationManager.LocationChanged -= HandleLocationChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the messages on navigation to another page.
|
||||
/// </summary>
|
||||
private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
RefreshAddMessages();
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
@inherits ComponentBase
|
||||
|
||||
<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">
|
||||
<svg class="w-5 h-5 mr-2.5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg>
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
@foreach (var item in BreadcrumbItems)
|
||||
{
|
||||
@if (item.Url is not null)
|
||||
{
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
|
||||
<a href="@item.Url" class="ml-1 text-gray-700 hover:text-primary-600 md:ml-2 dark:text-gray-300 dark:hover:text-primary-500">@item.DisplayName</a>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
|
||||
@item.DisplayName
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
<GlobalNotificationDisplay />
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets the list of breadcrumb items.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<BreadcrumbItem> BreadcrumbItems { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
// Remove first item if it is the home page
|
||||
if (BreadcrumbItems.Any() && BreadcrumbItems[0].DisplayName == "Home")
|
||||
{
|
||||
BreadcrumbItems.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
@using System.Timers
|
||||
|
||||
<button @onclick="HandleClick"
|
||||
disabled="@IsRefreshing"
|
||||
class="@GetButtonClasses()">
|
||||
<svg class="@GetIconClasses()" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
<span class="ml-2">@ButtonText</span>
|
||||
</button>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The event to call in the parent when the button is clicked.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnRefresh { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The text to display on the button.
|
||||
/// </summary>
|
||||
[Parameter] public string ButtonText { get; set; } = "Refresh";
|
||||
|
||||
private bool IsRefreshing;
|
||||
private Timer Timer = new();
|
||||
|
||||
private async Task HandleClick()
|
||||
{
|
||||
if (IsRefreshing) return;
|
||||
|
||||
IsRefreshing = true;
|
||||
await OnRefresh.InvokeAsync();
|
||||
|
||||
Timer = new Timer(500);
|
||||
Timer.Elapsed += (sender, args) =>
|
||||
{
|
||||
IsRefreshing = false;
|
||||
Timer.Dispose();
|
||||
InvokeAsync(StateHasChanged);
|
||||
};
|
||||
Timer.Start();
|
||||
}
|
||||
|
||||
private string GetButtonClasses()
|
||||
{
|
||||
return $"flex items-center px-3 py-2 text-sm font-medium text-white rounded-lg focus:outline-none focus:ring-4 focus:ring-primary-300 dark:focus:ring-primary-800 {(IsRefreshing ? "bg-gray-400 cursor-not-allowed" : "bg-primary-700 hover:bg-primary-800 dark:bg-primary-600 dark:hover:bg-primary-700")}";
|
||||
}
|
||||
|
||||
private string GetIconClasses()
|
||||
{
|
||||
return $"w-4 h-4 {(IsRefreshing ? "animate-spin" : "")}";
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@
|
||||
private bool SmtpStatus;
|
||||
private bool SmtpPending;
|
||||
private DateTime LastHeartbeat;
|
||||
private readonly SemaphoreSlim InitLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// The interval in milliseconds for refreshing the service status.
|
||||
@@ -29,9 +28,9 @@
|
||||
private readonly int AutoRefreshInterval = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// The timer for refreshing the service status.
|
||||
/// CancellationTokenSource for the timer.
|
||||
/// </summary>
|
||||
private Timer? Timer;
|
||||
private CancellationTokenSource? _timerCancellationTokenSource;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
@@ -40,10 +39,20 @@
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
Timer = new Timer(async _ =>
|
||||
{
|
||||
await InitPage();
|
||||
}, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(AutoRefreshInterval));
|
||||
_timerCancellationTokenSource = new CancellationTokenSource();
|
||||
_ = RunPeriodicRefreshAsync(_timerCancellationTokenSource.Token);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the service status periodically while waiting for specified amount of ms in between.
|
||||
/// </summary>
|
||||
private async Task RunPeriodicRefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await InitPage();
|
||||
await Task.Delay(AutoRefreshInterval, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +63,8 @@
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
// Dispose of the timer if it exists.
|
||||
Timer?.Dispose();
|
||||
_timerCancellationTokenSource?.Cancel();
|
||||
_timerCancellationTokenSource?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,51 +135,29 @@
|
||||
/// </summary>
|
||||
private async Task InitPage()
|
||||
{
|
||||
if (InitInProgress)
|
||||
if (InitInProgress || SmtpPending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await InitLock.WaitAsync();
|
||||
InitInProgress = true;
|
||||
var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
ServiceStatus = await dbContext.WorkerServiceStatuses.ToListAsync();
|
||||
|
||||
if (InitInProgress)
|
||||
var smtpEntry = ServiceStatus.Find(x => x.ServiceName == "AliasVault.SmtpService");
|
||||
if (smtpEntry != null)
|
||||
{
|
||||
return;
|
||||
LastHeartbeat = smtpEntry.Heartbeat;
|
||||
SmtpStatus = IsHeartbeatValid() && smtpEntry.CurrentStatus == "Started";
|
||||
}
|
||||
|
||||
if (!SmtpPending)
|
||||
{
|
||||
InitInProgress = true;
|
||||
|
||||
try
|
||||
{
|
||||
InitInProgress = true;
|
||||
var dbContext = await DbContextFactory.CreateDbContextAsync();
|
||||
ServiceStatus = await dbContext.WorkerServiceStatuses.ToListAsync();
|
||||
// Service status checks if the status is "Started" and was lastAlive
|
||||
// (so actually reported itself) in the last 5 minutes.
|
||||
var smtpEntry = ServiceStatus.Find(x => x.ServiceName == "AliasVault.SmtpService");
|
||||
if (smtpEntry != null)
|
||||
{
|
||||
LastHeartbeat = smtpEntry.Heartbeat;
|
||||
SmtpStatus = IsHeartbeatValid() && smtpEntry.CurrentStatus == "Started";
|
||||
}
|
||||
|
||||
InitInProgress = false;
|
||||
|
||||
await InvokeAsync(() => StateHasChanged());
|
||||
}
|
||||
finally
|
||||
{
|
||||
InitInProgress = false;
|
||||
}
|
||||
}
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
finally
|
||||
{
|
||||
InitLock.Release();
|
||||
InitInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<footer class="md:flex md:items-center md:justify-between px-4 2xl:px-0 py-6 md:py-10">
|
||||
<p class="text-sm text-center text-gray-500 mb-4 md:mb-0">
|
||||
© 2024 AliasVault. All rights reserved.
|
||||
@using AliasVault.Shared.Core
|
||||
<footer class="md:flex md:items-center md:justify-between px-4 2xl:px-0 py-6 md:py-10">
|
||||
<p class="text-sm text-center text-gray-500 mb-4 lg:mb-0">
|
||||
© 2024 <span>@AppInfo.ApplicationName v@(AppInfo.GetFullVersion())</span>. All rights reserved.
|
||||
</p>
|
||||
<ul class="flex flex-wrap items-center justify-center">
|
||||
<li><a href="#" class="mr-4 text-sm font-normal text-gray-500 hover:underline md:mr-6 dark:text-gray-400">Terms and conditions</a></li>
|
||||
<li><a href="#" class="mr-4 text-sm font-normal text-gray-500 hover:underline md:mr-6 dark:text-gray-400">License</a></li>
|
||||
<li><a href="https://github.com/lanedirt/AliasVault/blob/main/LICENSE.md" class="mr-4 text-sm font-normal text-gray-500 hover:underline md:mr-6 dark:text-gray-400">License</a></li>
|
||||
<li><a href="https://github.com/lanedirt/AliasVault" target="_blank" class="text-sm font-normal text-gray-500 hover:underline dark:text-gray-400">GitHub</a></li>
|
||||
</ul>
|
||||
</footer>
|
||||
|
||||
@@ -4,16 +4,19 @@
|
||||
@inject GlobalLoadingService GlobalLoadingService
|
||||
|
||||
<FullScreenLoadingIndicator @ref="LoadingIndicator" />
|
||||
<ConfirmModal />
|
||||
<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>
|
||||
<GlobalNotificationDisplay />
|
||||
@Body
|
||||
</main>
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
@implements IDisposable
|
||||
|
||||
<header>
|
||||
<nav class="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 py-3 px-4 bg-primary-100">
|
||||
<nav class="fixed z-30 w-full border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 py-3 px-4 bg-primary-100">
|
||||
<div class="flex justify-between items-center max-w-screen-2xl mx-auto">
|
||||
<div class="flex justify-start items-center">
|
||||
<a href="/" class="flex mr-14">
|
||||
<img src="/icon-trimmed.png" class="mr-3 h-8" alt="AliasVault Logo">
|
||||
<a href="/" class="flex mr-14 flex-shrink-0">
|
||||
<img src="/img/logo.svg" class="mr-3 h-8" alt="AliasVault Logo">
|
||||
<span class="self-center hidden sm:flex text-2xl font-semibold whitespace-nowrap dark:text-white">AliasVault</span>
|
||||
<span class="ps-2 self-center hidden sm:flex text-sm font-bold whitespace-nowrap text-white bg-red-600 rounded-full px-2 py-1 ml-2">Admin</span>
|
||||
</a>
|
||||
@@ -19,8 +19,11 @@
|
||||
<NavLink href="/emails" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Emails
|
||||
</NavLink>
|
||||
<NavLink href="/logs" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Logs
|
||||
<NavLink href="/logging/general" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
General logs
|
||||
</NavLink>
|
||||
<NavLink href="/logging/auth" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Auth logs
|
||||
</NavLink>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -77,8 +80,23 @@
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="block border-b dark:border-gray-700">
|
||||
<NavLink href="/credentials" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Credentials
|
||||
<NavLink href="/users" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Users
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="block border-b dark:border-gray-700">
|
||||
<NavLink href="/emails" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Emails
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="block border-b dark:border-gray-700">
|
||||
<NavLink href="/logging/general" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
General logs
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="block border-b dark:border-gray-700">
|
||||
<NavLink href="/logging/auth" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
|
||||
Auth logs
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -27,6 +27,11 @@ public class UserViewModel
|
||||
/// </summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the user has two-factor authentication enabled.
|
||||
/// </summary>
|
||||
public bool TwoFactorEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vault count.
|
||||
/// </summary>
|
||||
|
||||
@@ -15,23 +15,21 @@
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
<div>
|
||||
<label for="old-password" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Old password</label>
|
||||
<InputText type="password" @bind-Value="Input.OldPassword" id="old-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" autocomplete="current-password" aria-required="true" placeholder="Please enter your old password."/>
|
||||
<InputText type="password" @bind-Value="Input.OldPassword" id="old-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" autocomplete="current-password" aria-required="true" placeholder="Please enter your old password."/>
|
||||
<ValidationMessage For="() => Input.OldPassword" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">New password</label>
|
||||
<InputText type="password" @bind-Value="Input.NewPassword" id="new-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password."/>
|
||||
<InputText type="password" @bind-Value="Input.NewPassword" id="new-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password."/>
|
||||
<ValidationMessage For="() => Input.NewPassword" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Confirm password</label>
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="confirm-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="confirm-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 rounded-md">
|
||||
Update password
|
||||
</button>
|
||||
<SubmitButton>Update password</SubmitButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
@@ -61,7 +59,7 @@
|
||||
|
||||
Logger.LogInformation("User changed their password successfully.");
|
||||
|
||||
GlobalNotificationService.AddSuccessMessage("Your password has been changed.", true);
|
||||
GlobalNotificationService.AddSuccessMessage("Your password has been changed.");
|
||||
|
||||
NavigationService.RedirectToCurrentPage();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/account/manage/disable-2fa"
|
||||
|
||||
@using AliasVault.Shared.Models.Enums
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@@ -41,14 +42,15 @@
|
||||
var disable2FaResult = await UserManager.SetTwoFactorEnabledAsync(UserService.User(), false);
|
||||
if (!disable2FaResult.Succeeded)
|
||||
{
|
||||
await AuthLoggingService.LogAuthEventFailAsync(UserService.User().UserName!, AuthEventType.TwoFactorAuthDisable, AuthFailureReason.Unknown);
|
||||
throw new InvalidOperationException("Unexpected error occurred disabling 2FA.");
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(UserService.User());
|
||||
Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId);
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(UserService.User().UserName!, AuthEventType.TwoFactorAuthDisable);
|
||||
Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", UserService.User().Id);
|
||||
|
||||
// Reload current page.
|
||||
NavigationService.RedirectTo(NavigationService.Uri, forceLoad: true);
|
||||
NavigationService.RedirectTo("account/manage/2fa");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using System.Globalization
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using AliasVault.Shared.Models.Enums
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
|
||||
@inject UserManager<AdminUser> UserManager
|
||||
@@ -12,9 +13,9 @@
|
||||
|
||||
<LayoutPageTitle>Configure authenticator app</LayoutPageTitle>
|
||||
|
||||
@if (recoveryCodes is not null)
|
||||
@if (RecoveryCodes is not null)
|
||||
{
|
||||
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()"/>
|
||||
<ShowRecoveryCodes RecoveryCodes="RecoveryCodes.ToArray()"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -34,8 +35,8 @@ else
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="text-gray-700 dark:text-gray-300">Scan the QR Code or enter this key <kbd class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg dark:bg-gray-600 dark:text-gray-100 dark:border-gray-500">@sharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
|
||||
<div id="authenticator-uri" data-url="@authenticatorUri" class="mt-4"></div>
|
||||
<p class="text-gray-700 dark:text-gray-300">Scan the QR Code or enter this key <kbd class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg dark:bg-gray-600 dark:text-gray-100 dark:border-gray-500">@SharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
|
||||
<div id="authenticator-uri" data-url="@AuthenticatorUri" class="mt-4"></div>
|
||||
</li>
|
||||
<li>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
@@ -51,9 +52,7 @@ else
|
||||
<ValidationMessage For="() => Input.Code" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 rounded-md dark:bg-primary-500 dark:hover:bg-primary-600">
|
||||
Verify
|
||||
</button>
|
||||
<SubmitButton>Verify</SubmitButton>
|
||||
</div>
|
||||
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
|
||||
</EditForm>
|
||||
@@ -67,9 +66,9 @@ else
|
||||
@code {
|
||||
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
|
||||
|
||||
private string? sharedKey;
|
||||
private string? authenticatorUri;
|
||||
private IEnumerable<string>? recoveryCodes;
|
||||
private string? SharedKey { get; set; }
|
||||
private string? AuthenticatorUri { get; set; }
|
||||
private IEnumerable<string>? RecoveryCodes { get; set; }
|
||||
|
||||
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
|
||||
|
||||
@@ -77,9 +76,7 @@ else
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
await LoadSharedKeyAndQrCodeUriAsync(UserService.User());
|
||||
|
||||
await JsInvokeService.RetryInvokeAsync("generateQrCode", TimeSpan.Zero, 5, "authenticator-uri");
|
||||
}
|
||||
|
||||
@@ -88,24 +85,23 @@ else
|
||||
// Strip spaces and hyphens
|
||||
var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
|
||||
|
||||
var is2faTokenValid = await UserManager.VerifyTwoFactorTokenAsync(
|
||||
var is2FaTokenValid = await UserManager.VerifyTwoFactorTokenAsync(
|
||||
UserService.User(), UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
|
||||
|
||||
if (!is2faTokenValid)
|
||||
if (!is2FaTokenValid)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage("Error: Verification code is invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
await UserManager.SetTwoFactorEnabledAsync(UserService.User(), true);
|
||||
var userId = await UserManager.GetUserIdAsync(UserService.User());
|
||||
Logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId);
|
||||
|
||||
await AuthLoggingService.LogAuthEventSuccessAsync(UserService.User().UserName!, AuthEventType.TwoFactorAuthEnable);
|
||||
Logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", UserService.User().Id);
|
||||
GlobalNotificationService.AddSuccessMessage("Your authenticator app has been verified.");
|
||||
|
||||
if (await UserManager.CountRecoveryCodesAsync(UserService.User()) == 0)
|
||||
{
|
||||
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10);
|
||||
RecoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -124,10 +120,10 @@ else
|
||||
unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
|
||||
}
|
||||
|
||||
sharedKey = FormatKey(unformattedKey!);
|
||||
SharedKey = FormatKey(unformattedKey!);
|
||||
|
||||
var username = await UserManager.GetUserNameAsync(user);
|
||||
authenticatorUri = GenerateQrCodeUri(username!, unformattedKey!);
|
||||
AuthenticatorUri = GenerateQrCodeUri(username!, unformattedKey!);
|
||||
}
|
||||
|
||||
private string FormatKey(string unformattedKey)
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
<ValidationMessage For="() => Input.PhoneNumber" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 rounded-md dark:bg-primary-500 dark:hover:bg-primary-600">
|
||||
Save
|
||||
</button>
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<LayoutPageTitle>Reset authenticator key</LayoutPageTitle>
|
||||
|
||||
<h3 class="text-xl font-bold mb-4">Reset authenticator key</h3>
|
||||
<h3 class="text-xl font-bold mb-4 dark:text-white">Reset authenticator key</h3>
|
||||
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4 mb-4" role="alert">
|
||||
<p class="mb-2">
|
||||
<svg class="inline w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -23,7 +23,7 @@
|
||||
<div>
|
||||
<form @formname="reset-authenticator" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken/>
|
||||
<button class="bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded" type="submit">Reset authenticator key</button>
|
||||
<SubmitButton>Reset authenticator key</SubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
var userId = await UserManager.GetUserIdAsync(UserService.User());
|
||||
Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId);
|
||||
|
||||
GlobalNotificationService.AddSuccessMessage("Your authenticator app key has been reset, you will need to re-configure your authenticator app using the new key.", true);
|
||||
GlobalNotificationService.AddSuccessMessage("Your authenticator app key has been reset, you will need to re-configure your authenticator app using the new key.");
|
||||
|
||||
NavigationService.RedirectTo(
|
||||
"account/manage/2fa");
|
||||
|
||||
@@ -46,18 +46,12 @@
|
||||
<div class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
@if (!hasAuthenticator)
|
||||
{
|
||||
<a href="account/manage/enable-authenticator" class="inline-block px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm text-center focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
Add authenticator app
|
||||
</a>
|
||||
<LinkButton Href="account/manage/enable-authenticator" Color="primary" Text="Add authenticator app" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="account/manage/enable-authenticator" class="inline-block px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm text-center focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
Set up authenticator app
|
||||
</a>
|
||||
<a href="account/manage/reset-authenticator" class="inline-block px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm text-center focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
Reset authenticator app
|
||||
</a>
|
||||
<LinkButton Href="account/manage/enable-authenticator" Color="primary" Text="Add authenticator app" />
|
||||
<LinkButton Href="account/manage/reset-authenticator" Color="primary" Text="Reset authenticator app" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,11 @@
|
||||
@using AliasVault.Admin.Main.Layout
|
||||
@layout MainLayout
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<GlobalNotificationDisplay />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Manage account</h1>
|
||||
</div>
|
||||
<p>Manage your profile here.</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="Manage account"
|
||||
Description="Manage your profile here.">
|
||||
</PageHeader>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<hr class="mb-6 border-t border-gray-300"/>
|
||||
@@ -24,3 +19,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets the breadcrumb items for the page. A default set of breadcrumbs is added in the parent OnInitialized method.
|
||||
/// </summary>
|
||||
private List<BreadcrumbItem> BreadcrumbItems { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
// Add base breadcrumbs.
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Home", Url = "/" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
<ul class="flex flex-col space-y-1">
|
||||
<li>
|
||||
<NavLink href="account/manage" Match="NavLinkMatch.All" class="block px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150">Profile</NavLink>
|
||||
<NavLink href="account/manage" Match="NavLinkMatch.All" class="block px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150">Profile</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="account/manage/change-password" class="block px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150">Password</NavLink>
|
||||
<NavLink href="account/manage/change-password" class="block px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150">Password</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink href="account/manage/2fa" class="block px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150">Two-factor authentication</NavLink>
|
||||
<NavLink href="account/manage/2fa" class="block px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150">Two-factor authentication</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
@page "/emails"
|
||||
@using AliasVault.RazorComponents
|
||||
@using Azure
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>Emails</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Emails</h1>
|
||||
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
|
||||
</div>
|
||||
<p>This page gives an overview of recently received mails by this AliasVault server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="Emails"
|
||||
Description="This page gives an overview of recently received mails by this AliasVault server. Note that all email fields except 'To' are encrypted with the public key of the user and cannot be decrypted by the server.">
|
||||
<CustomActions>
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
@@ -24,59 +22,54 @@ else
|
||||
<div class="overflow-x-auto px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border mt-8">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Time</th>
|
||||
<th scope="col" class="px-4 py-3">From</th>
|
||||
<th scope="col" class="px-4 py-3">To</th>
|
||||
<th scope="col" class="px-4 py-3">Subject</th>
|
||||
<th scope="col" class="px-4 py-3">Preview</th>
|
||||
<th scope="col" class="px-4 py-3">Attachments</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var email in EmailList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">
|
||||
@email.Id
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@email.DateSystem.ToString("yyyy-MM-dd HH:mm")
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@(email.FromLocal.Length > 15 ? email.FromLocal.Substring(0, 15) : email.FromLocal)@@@(email.FromDomain.Length > 15 ? email.FromDomain.Substring(0, 15) : email.FromDomain)
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@email.ToLocal@@@email.ToDomain
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@(email.Subject.Length > 30 ? email.Subject.Substring(0, 30) : email.Subject)
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="line-clamp-1">
|
||||
@(email.MessagePreview?.Length > 30 ? email.MessagePreview.Substring(0, 30) : email.MessagePreview)
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@email.Attachments.Count
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var email in EmailList)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@email.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@email.DateSystem.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@(email.FromLocal.Length > 15 ? email.FromLocal.Substring(0, 15) : email.FromLocal)@@@(email.FromDomain.Length > 15 ? email.FromDomain.Substring(0, 15) : email.FromDomain)</SortableTableColumn>
|
||||
<SortableTableColumn>@email.ToLocal@@@email.ToDomain</SortableTableColumn>
|
||||
<SortableTableColumn>@(email.Subject.Length > 30 ? email.Subject.Substring(0, 30) : email.Subject)</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
<span class="line-clamp-1">
|
||||
@(email.MessagePreview?.Length > 30 ? email.MessagePreview.Substring(0, 30) : email.MessagePreview)
|
||||
</span>
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>@email.Attachments.Count</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private readonly List<TableColumn> _tableColumns = [
|
||||
new TableColumn { Title = "ID", PropertyName = "Id" },
|
||||
new TableColumn { Title = "Time", PropertyName = "DateSystem" },
|
||||
new TableColumn { Title = "From", PropertyName = "From" },
|
||||
new TableColumn { Title = "To", PropertyName = "To" },
|
||||
new TableColumn { Title = "Subject", PropertyName = "Subject" },
|
||||
new TableColumn { Title = "Preview", PropertyName = "MessagePreview" },
|
||||
new TableColumn { Title = "Attachments", PropertyName = "Attachments" },
|
||||
];
|
||||
|
||||
private List<Email> EmailList { get; set; } = [];
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private int CurrentPage { get; set; } = 1;
|
||||
private int PageSize { get; set; } = 50;
|
||||
private int TotalRecords { get; set; }
|
||||
|
||||
private string SortColumn { get; set; } = "Id";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
|
||||
private async Task HandleSortChanged((string column, SortDirection direction) sort)
|
||||
{
|
||||
SortColumn = sort.column;
|
||||
SortDirection = sort.direction;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
@@ -97,9 +90,53 @@ else
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
TotalRecords = await DbContext.Emails.CountAsync();
|
||||
EmailList = await DbContext.Emails
|
||||
.OrderByDescending(x => x.DateSystem)
|
||||
IQueryable<Email> query = DbContext.Emails;
|
||||
|
||||
// Apply sort
|
||||
switch (SortColumn)
|
||||
{
|
||||
case "Id":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Id)
|
||||
: query.OrderByDescending(x => x.Id);
|
||||
break;
|
||||
case "DateSystem":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.DateSystem)
|
||||
: query.OrderByDescending(x => x.DateSystem);
|
||||
break;
|
||||
case "From":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.FromLocal + "@" + x.FromDomain)
|
||||
: query.OrderByDescending(x => x.FromLocal + "@" + x.FromDomain);
|
||||
break;
|
||||
case "To":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.ToLocal + "@" + x.ToDomain)
|
||||
: query.OrderByDescending(x => x.ToLocal + "@" + x.ToDomain);
|
||||
break;
|
||||
case "Subject":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Subject)
|
||||
: query.OrderByDescending(x => x.Subject);
|
||||
break;
|
||||
case "MessagePreview":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.MessagePreview)
|
||||
: query.OrderByDescending(x => x.MessagePreview);
|
||||
break;
|
||||
case "Attachments":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Attachments.Count)
|
||||
: query.OrderByDescending(x => x.Attachments.Count);
|
||||
break;
|
||||
default:
|
||||
query = query.OrderByDescending(x => x.DateSystem);
|
||||
break;
|
||||
}
|
||||
|
||||
TotalRecords = await query.CountAsync();
|
||||
EmailList = await query
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
|
||||
<LayoutPageTitle>Home</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">AliasVault Admin</h1>
|
||||
</div>
|
||||
<p>Welcome to the AliasVault admin portal.</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="AliasVault Admin"
|
||||
Description="Welcome to the AliasVault admin portal.">
|
||||
</PageHeader>
|
||||
|
||||
@code {
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
|
||||
// Redirect to users page.
|
||||
NavigationService.RedirectTo("/users");
|
||||
}
|
||||
}
|
||||
|
||||
225
src/AliasVault.Admin/Main/Pages/Logging/Auth.razor
Normal file
225
src/AliasVault.Admin/Main/Pages/Logging/Auth.razor
Normal file
@@ -0,0 +1,225 @@
|
||||
@page "/logging/auth"
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
@using AliasVault.Shared.Models.Enums
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>Auth logs</LayoutPageTitle>
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="Auth logs"
|
||||
Description="This page gives an overview of recent auth attempts.">
|
||||
<CustomActions>
|
||||
<DeleteButton OnClick="DeleteLogsWithConfirmation" ButtonText="Delete all logs" />
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<div class="mb-4 flex space-x-4">
|
||||
<div class="flex w-full">
|
||||
<div class="w-2/3 pr-2">
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
<div class="w-1/3 pl-2">
|
||||
<select @bind="SelectedEventType" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
<option value="">All event types</option>
|
||||
@foreach (var eventType in Enum.GetValues<AuthEventType>())
|
||||
{
|
||||
<option value="@eventType">@eventType</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var log in LogList)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@log.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@log.Timestamp.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@log.Username</SortableTableColumn>
|
||||
<SortableTableColumn>@log.EventType</SortableTableColumn>
|
||||
<SortableTableColumn><StatusPill Enabled="log.IsSuccess" TextTrue="Success" TextFalse="Failed" /></SortableTableColumn>
|
||||
<SortableTableColumn>@log.IpAddress</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private readonly List<TableColumn> _tableColumns = [
|
||||
new TableColumn { Title = "ID", PropertyName = "Id" },
|
||||
new TableColumn { Title = "Time", PropertyName = "Timestamp" },
|
||||
new TableColumn { Title = "Username", PropertyName = "Username" },
|
||||
new TableColumn { Title = "Event", PropertyName = "EventType" },
|
||||
new TableColumn { Title = "Success", PropertyName = "IsSuccess" },
|
||||
new TableColumn { Title = "IP", PropertyName = "IpAddress" },
|
||||
];
|
||||
|
||||
private List<AuthLog> LogList { get; set; } = [];
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private int CurrentPage { get; set; } = 1;
|
||||
private int PageSize { get; set; } = 50;
|
||||
private int TotalRecords { get; set; }
|
||||
|
||||
private string _searchTerm = string.Empty;
|
||||
private string SearchTerm
|
||||
{
|
||||
get => _searchTerm;
|
||||
set
|
||||
{
|
||||
if (_searchTerm != value)
|
||||
{
|
||||
_searchTerm = value;
|
||||
_ = RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string _selectedEventType = string.Empty;
|
||||
private string SelectedEventType
|
||||
{
|
||||
get => _selectedEventType;
|
||||
set
|
||||
{
|
||||
if (_selectedEventType != value)
|
||||
{
|
||||
_selectedEventType = value;
|
||||
_ = RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string SortColumn { get; set; } = "Id";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
|
||||
private async Task HandleSortChanged((string column, SortDirection direction) sort)
|
||||
{
|
||||
SortColumn = sort.column;
|
||||
SortDirection = sort.direction;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandlePageChanged(int newPage)
|
||||
{
|
||||
CurrentPage = newPage;
|
||||
_ = RefreshData();
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var query = DbContext.AuthLogs.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(SearchTerm))
|
||||
{
|
||||
query = query.Where(x => EF.Functions.Like(x.Username.ToLower(), "%" + SearchTerm.ToLower() + "%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(SelectedEventType))
|
||||
{
|
||||
var success = Enum.TryParse<AuthEventType>(SelectedEventType, out var eventType);
|
||||
if (success)
|
||||
{
|
||||
query = query.Where(x => x.EventType == eventType);
|
||||
}
|
||||
}
|
||||
|
||||
query = ApplySort(query);
|
||||
|
||||
TotalRecords = await query.CountAsync();
|
||||
LogList = await query
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply sort to the query.
|
||||
/// </summary>
|
||||
private IQueryable<AuthLog> ApplySort(IQueryable<AuthLog> query)
|
||||
{
|
||||
// Apply sort.
|
||||
switch (SortColumn)
|
||||
{
|
||||
case "Timestamp":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Timestamp)
|
||||
: query.OrderByDescending(x => x.Timestamp);
|
||||
break;
|
||||
case "Username":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Username)
|
||||
: query.OrderByDescending(x => x.Username);
|
||||
break;
|
||||
case "EventType":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.EventType)
|
||||
: query.OrderByDescending(x => x.EventType);
|
||||
break;
|
||||
case "IsSuccess":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.IsSuccess)
|
||||
: query.OrderByDescending(x => x.IsSuccess);
|
||||
break;
|
||||
case "IpAddress":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.IpAddress)
|
||||
: query.OrderByDescending(x => x.IpAddress);
|
||||
break;
|
||||
default:
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Id)
|
||||
: query.OrderByDescending(x => x.Id);
|
||||
break;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private async Task DeleteLogsWithConfirmation()
|
||||
{
|
||||
if (await ConfirmModalService.ShowConfirmation("Confirm Delete", "Are you sure you want to delete all logs? This action cannot be undone."))
|
||||
{
|
||||
await DeleteLogs();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteLogs()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
DbContext.AuthLogs.RemoveRange(DbContext.AuthLogs);
|
||||
await DbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
228
src/AliasVault.Admin/Main/Pages/Logging/General.razor
Normal file
228
src/AliasVault.Admin/Main/Pages/Logging/General.razor
Normal file
@@ -0,0 +1,228 @@
|
||||
@page "/logging/general"
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>System logs</LayoutPageTitle>
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="General logs"
|
||||
Description="This page gives an overview of recent system logs.">
|
||||
<CustomActions>
|
||||
<DeleteButton OnClick="DeleteLogsWithConfirmation" ButtonText="Delete all logs" />
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<div class="mb-4 flex space-x-4">
|
||||
<div class="flex w-full">
|
||||
<div class="w-2/3 pr-2">
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
<div class="w-1/3 pl-2">
|
||||
<select @bind="SelectedServiceName" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
<option value="">All Services</option>
|
||||
@foreach (var service in ServiceNames)
|
||||
{
|
||||
<option value="@service">@service</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var log in LogList)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@log.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@log.TimeStamp.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@log.Application</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
@{
|
||||
string bgColor = log.Level switch
|
||||
{
|
||||
"Information" => "bg-blue-500",
|
||||
"Error" => "bg-red-500",
|
||||
"Warning" => "bg-yellow-500",
|
||||
"Debug" => "bg-green-500",
|
||||
_ => "bg-gray-500"
|
||||
};
|
||||
}
|
||||
<span class="px-2 py-1 rounded-full text-white @bgColor">
|
||||
@log.Level
|
||||
</span>
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn Title="@log.Exception">
|
||||
@if (log.SourceContext.Length > 0)
|
||||
{
|
||||
<span>@log.SourceContext: </span>
|
||||
}
|
||||
@log.Message
|
||||
</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private readonly List<TableColumn> _tableColumns = [
|
||||
new TableColumn { Title = "ID", PropertyName = "Id" },
|
||||
new TableColumn { Title = "Time", PropertyName = "Timestamp" },
|
||||
new TableColumn { Title = "Application", PropertyName = "Application" },
|
||||
new TableColumn { Title = "Level", PropertyName = "Level" },
|
||||
new TableColumn { Title = "Message", PropertyName = "Message" },
|
||||
];
|
||||
|
||||
private List<Log> LogList { get; set; } = [];
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private int CurrentPage { get; set; } = 1;
|
||||
private int PageSize { get; set; } = 50;
|
||||
private int TotalRecords { get; set; }
|
||||
|
||||
private string _searchTerm = string.Empty;
|
||||
private string SearchTerm
|
||||
{
|
||||
get => _searchTerm;
|
||||
set
|
||||
{
|
||||
if (_searchTerm != value)
|
||||
{
|
||||
_searchTerm = value;
|
||||
_ = RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string _selectedServiceName = string.Empty;
|
||||
private string SelectedServiceName
|
||||
{
|
||||
get => _selectedServiceName;
|
||||
set
|
||||
{
|
||||
if (_selectedServiceName != value)
|
||||
{
|
||||
_selectedServiceName = value;
|
||||
_ = RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> ServiceNames { get; set; } = [];
|
||||
|
||||
private string SortColumn { get; set; } = "Id";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
|
||||
private async Task HandleSortChanged((string column, SortDirection direction) sort)
|
||||
{
|
||||
SortColumn = sort.column;
|
||||
SortDirection = sort.direction;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
ServiceNames = await DbContext.Logs.Select(l => l.Application).Distinct().ToListAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandlePageChanged(int newPage)
|
||||
{
|
||||
CurrentPage = newPage;
|
||||
_ = RefreshData();
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var query = DbContext.Logs.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(SearchTerm))
|
||||
{
|
||||
query = query.Where(x => EF.Functions.Like(x.Application.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like(x.Message.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like(x.Level.ToLower(), "%" + SearchTerm.ToLower() + "%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(SelectedServiceName))
|
||||
{
|
||||
query = query.Where(x => x.Application == SelectedServiceName);
|
||||
}
|
||||
|
||||
// Apply sort.
|
||||
switch (SortColumn)
|
||||
{
|
||||
case "Application":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Application)
|
||||
: query.OrderByDescending(x => x.Application);
|
||||
break;
|
||||
case "Message":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Message)
|
||||
: query.OrderByDescending(x => x.Message);
|
||||
break;
|
||||
case "Level":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Level)
|
||||
: query.OrderByDescending(x => x.Level);
|
||||
break;
|
||||
case "Timestamp":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.TimeStamp)
|
||||
: query.OrderByDescending(x => x.TimeStamp);
|
||||
break;
|
||||
default:
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Id)
|
||||
: query.OrderByDescending(x => x.Id);
|
||||
break;
|
||||
}
|
||||
|
||||
TotalRecords = await query.CountAsync();
|
||||
LogList = await query
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task DeleteLogsWithConfirmation()
|
||||
{
|
||||
if (await ConfirmModalService.ShowConfirmation("Confirm Delete", "Are you sure you want to delete all logs? This action cannot be undone."))
|
||||
{
|
||||
await DeleteLogs();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteLogs()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
DbContext.Logs.RemoveRange(DbContext.Logs);
|
||||
await DbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
@page "/logs"
|
||||
@using AliasVault.RazorComponents
|
||||
|
||||
<LayoutPageTitle>Logs</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Logs</h1>
|
||||
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
|
||||
</div>
|
||||
<p>This page gives an overview of recent system logs.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-4">
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<div class="mb-4 flex space-x-4">
|
||||
<div class="flex w-full">
|
||||
<div class="w-2/3 pr-2">
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div class="w-1/3 pl-2">
|
||||
<select @bind="SelectedServiceName" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">All Services</option>
|
||||
@foreach (var service in ServiceNames)
|
||||
{
|
||||
<option value="@service">@service</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Time</th>
|
||||
<th scope="col" class="px-4 py-3">Application</th>
|
||||
<th scope="col" class="px-4 py-3">Level</th>
|
||||
<th scope="col" class="px-4 py-3">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var log in LogList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">@log.Id</td>
|
||||
<td class="px-4 py-3">@log.TimeStamp.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">@log.Application</td>
|
||||
|
||||
@{
|
||||
string bgColor = log.Level switch
|
||||
{
|
||||
"Information" => "bg-blue-500",
|
||||
"Error" => "bg-red-500",
|
||||
"Warning" => "bg-yellow-500",
|
||||
"Debug" => "bg-green-500",
|
||||
_ => "bg-gray-500"
|
||||
};
|
||||
}
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 rounded-full text-white @bgColor">
|
||||
@log.Level
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 line-clamp-1" title="@log.Exception">@log.Message</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Log> LogList { get; set; } = [];
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private int CurrentPage { get; set; } = 1;
|
||||
private int PageSize { get; set; } = 50;
|
||||
private int TotalRecords { get; set; }
|
||||
|
||||
private string _searchTerm = string.Empty;
|
||||
private string SearchTerm
|
||||
{
|
||||
get => _searchTerm;
|
||||
set
|
||||
{
|
||||
if (_searchTerm != value)
|
||||
{
|
||||
_searchTerm = value;
|
||||
_ = RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string _selectedServiceName = string.Empty;
|
||||
private string SelectedServiceName
|
||||
{
|
||||
get => _selectedServiceName;
|
||||
set
|
||||
{
|
||||
if (_selectedServiceName != value)
|
||||
{
|
||||
_selectedServiceName = value;
|
||||
_ = RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> ServiceNames { get; set; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
ServiceNames = await DbContext.Logs.Select(l => l.Application).Distinct().ToListAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private void HandlePageChanged(int newPage)
|
||||
{
|
||||
CurrentPage = newPage;
|
||||
_ = RefreshData();
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var query = DbContext.Logs.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(SearchTerm))
|
||||
{
|
||||
query = query.Where(x => EF.Functions.Like(x.Application.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like(x.Message.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like(x.Level.ToLower(), "%" + SearchTerm.ToLower() + "%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(SelectedServiceName))
|
||||
{
|
||||
query = query.Where(x => x.Application == SelectedServiceName);
|
||||
}
|
||||
|
||||
TotalRecords = await query.CountAsync();
|
||||
LogList = await query
|
||||
.OrderByDescending(x => x.Id)
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@
|
||||
namespace AliasVault.Admin.Main.Pages;
|
||||
|
||||
using AliasServerDb;
|
||||
using AliasVault.Admin.Main.Models;
|
||||
using AliasVault.Admin.Services;
|
||||
using AliasVault.Auth;
|
||||
using AliasVault.RazorComponents.Models;
|
||||
using AliasVault.RazorComponents.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -65,6 +67,18 @@ public class MainBase : OwningComponentBase
|
||||
[Inject]
|
||||
protected GlobalLoadingService GlobalLoadingSpinner { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the auth logging service.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected AuthLoggingService AuthLoggingService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the confirm modal service.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected ConfirmModalService ConfirmModalService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the injected JSRuntime instance.
|
||||
/// </summary>
|
||||
@@ -92,6 +106,12 @@ public class MainBase : OwningComponentBase
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
// Check if 2FA is enabled. If not, show a persistent notification.
|
||||
if (!UserService.User().TwoFactorEnabled)
|
||||
{
|
||||
GlobalNotificationService.AddWarningMessage("Two-factor authentication is not enabled. Please enable it in Account Settings for better security.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
@page "/users/{id}/delete"
|
||||
@inherits MainBase
|
||||
@inject ILogger<Delete> Logger
|
||||
|
||||
<LayoutPageTitle>Delete user</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Delete user</h1>
|
||||
</div>
|
||||
<p>You can delete the user below.</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="Delete user"
|
||||
Description="You can delete the user below.">
|
||||
</PageHeader>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
@@ -24,19 +21,16 @@ else
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">User</h3>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Id</label>
|
||||
<div>@Id</div>
|
||||
<div class="text-gray-900 dark:text-white">@Id</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div>@Obj?.UserName</div>
|
||||
<div class="text-gray-900 dark:text-white">@Obj?.UserName</div>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<Button Color="danger" OnClick="DeleteConfirm">Yes, I'm sure</Button>
|
||||
<Button Color="secondary" OnClick="Cancel">No, cancel</Button>
|
||||
</div>
|
||||
<button @onclick="DeleteConfirm" class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm inline-flex items-center px-3 py-2.5 text-center mr-2 dark:focus:ring-red-900">
|
||||
Yes, I'm sure
|
||||
</button>
|
||||
|
||||
<button @onclick="Cancel" class="text-gray-900 bg-white hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 border border-gray-200 font-medium inline-flex items-center rounded-lg text-sm px-3 py-2.5 text-center dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700" data-drawer-hide="drawer-delete-product-default">
|
||||
No, cancel
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -86,6 +80,9 @@ else
|
||||
|
||||
GlobalLoadingSpinner.Show();
|
||||
|
||||
// Add log entry.
|
||||
Logger.LogWarning("Deleted user {UserName} ({UserId}).", Obj.UserName, Obj.Id);
|
||||
|
||||
DbContext.AliasVaultUsers.Remove(Obj);
|
||||
await DbContext.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
@page "/users"
|
||||
@using AliasVault.RazorComponents
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>Users</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Users</h1>
|
||||
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
|
||||
</div>
|
||||
<p>This page gives an overview of all registered users and the associated vaults.</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="Users"
|
||||
Description="This page gives an overview of all registered users and the associated vaults.">
|
||||
<CustomActions>
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
@@ -24,43 +23,43 @@ else
|
||||
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
|
||||
<div class="mb-4">
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search users..." class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Registered</th>
|
||||
<th scope="col" class="px-4 py-3">Username</th>
|
||||
<th scope="col" class="px-4 py-3"># Vaults</th>
|
||||
<th scope="col" class="px-4 py-3"># Email claims</th>
|
||||
<th scope="col" class="px-4 py-3">Storage</th>
|
||||
<th scope="col" class="px-4 py-3">Last vault update</th>
|
||||
<th scope="col" class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logTableBody">
|
||||
@foreach (var user in UserList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">@user.Id</td>
|
||||
<td class="px-4 py-3">@user.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">@user.UserName</td>
|
||||
<td class="px-4 py-3">@user.VaultCount</td>
|
||||
<td class="px-4 py-3">@user.EmailClaimCount</td>
|
||||
<td class="px-4 py-3">@Math.Round((double)user.VaultStorageInKb / 1024, 1) MB</td>
|
||||
<td class="px-4 py-3">@user.LastVaultUpdate.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="users/@user.Id" class="px-4 py-2 text-white bg-primary-600 rounded-lg hover:bg-primary-700 focus:ring-4 focus:ring-blue-300 dark:bg-primary-500 dark:hover:bg-primary-600 dark:focus:ring-primary-800">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var user in UserList)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@user.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@user.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@user.UserName</SortableTableColumn>
|
||||
<SortableTableColumn>@user.VaultCount</SortableTableColumn>
|
||||
<SortableTableColumn>@user.EmailClaimCount</SortableTableColumn>
|
||||
<SortableTableColumn>@Math.Round((double)user.VaultStorageInKb / 1024, 1) MB</SortableTableColumn>
|
||||
<SortableTableColumn><StatusPill Enabled="user.TwoFactorEnabled" /></SortableTableColumn>
|
||||
<SortableTableColumn>@user.LastVaultUpdate.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
<LinkButton Color="primary" Href="@($"users/{user.Id}")" Text="View" />
|
||||
</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private readonly List<TableColumn> _tableColumns = [
|
||||
new TableColumn { Title = "ID", PropertyName = "Id" },
|
||||
new TableColumn { Title = "Registered", PropertyName = "CreatedAt" },
|
||||
new TableColumn { Title = "Username", PropertyName = "UserName" },
|
||||
new TableColumn { Title = "# Vaults", PropertyName = "VaultCount" },
|
||||
new TableColumn { Title = "# Email claims", PropertyName = "EmailClaimCount" },
|
||||
new TableColumn { Title = "Storage", PropertyName = "VaultStorageInKb" },
|
||||
new TableColumn { Title = "2FA", PropertyName = "TwoFactorEnabled" },
|
||||
new TableColumn { Title = "LastVaultUpdate", PropertyName = "LastVaultUpdate" },
|
||||
new TableColumn { Title = "Actions", Sortable = false},
|
||||
];
|
||||
|
||||
private List<UserViewModel> UserList { get; set; } = [];
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private int CurrentPage { get; set; } = 1;
|
||||
@@ -81,6 +80,16 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private string SortColumn { get; set; } = "CreatedAt";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
|
||||
private async Task HandleSortChanged((string column, SortDirection direction) sort)
|
||||
{
|
||||
SortColumn = sort.column;
|
||||
SortDirection = sort.direction;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
@@ -105,14 +114,14 @@ else
|
||||
|
||||
if (SearchTerm.Length > 0)
|
||||
{
|
||||
query = query.Where(x => EF.Functions.Like(x.UserName!.ToLower(), "%" + SearchTerm.ToLower() + "%") ||
|
||||
EF.Functions.Like(x.Email!.ToLower(), "%" + SearchTerm.ToLower() + "%"));
|
||||
query = query.Where(x => EF.Functions.Like(x.UserName!.ToLower(), "%" + SearchTerm.ToLower() + "%"));
|
||||
}
|
||||
|
||||
TotalRecords = await query.CountAsync();
|
||||
// Apply sort.
|
||||
query = ApplySort(query);
|
||||
|
||||
TotalRecords = await query.CountAsync();
|
||||
var users = await query
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.Select(u => new
|
||||
@@ -120,6 +129,7 @@ else
|
||||
u.Id,
|
||||
u.UserName,
|
||||
u.CreatedAt,
|
||||
u.TwoFactorEnabled,
|
||||
Vaults = u.Vaults.Select(v => new
|
||||
{
|
||||
v.FileSize,
|
||||
@@ -136,14 +146,74 @@ else
|
||||
{
|
||||
Id = user.Id,
|
||||
UserName = user.UserName?.ToLower() ?? "N/A",
|
||||
TwoFactorEnabled = user.TwoFactorEnabled,
|
||||
CreatedAt = user.CreatedAt,
|
||||
VaultCount = user.Vaults.Count(),
|
||||
EmailClaimCount = user.EmailClaims.Count(),
|
||||
VaultStorageInKb = user.Vaults.Sum(x => x.FileSize),
|
||||
LastVaultUpdate = user.Vaults.Max(x => x.CreatedAt),
|
||||
LastVaultUpdate = user.Vaults.Any() ? user.Vaults.Max(x => x.CreatedAt) : user.CreatedAt,
|
||||
}).ToList();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply sort to the query.
|
||||
/// </summary>
|
||||
private IQueryable<AliasVaultUser> ApplySort(IQueryable<AliasVaultUser> query)
|
||||
{
|
||||
// Apply sort.
|
||||
switch (SortColumn)
|
||||
{
|
||||
case "Id":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Id)
|
||||
: query.OrderByDescending(x => x.Id);
|
||||
break;
|
||||
case "CreatedAt":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.CreatedAt)
|
||||
: query.OrderByDescending(x => x.CreatedAt);
|
||||
break;
|
||||
case "UserName":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.UserName)
|
||||
: query.OrderByDescending(x => x.UserName);
|
||||
break;
|
||||
case "VaultCount":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Vaults.Count)
|
||||
: query.OrderByDescending(x => x.Vaults.Count);
|
||||
break;
|
||||
case "EmailClaimCount":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.EmailClaims.Count)
|
||||
: query.OrderByDescending(x => x.EmailClaims.Count);
|
||||
break;
|
||||
case "VaultStorageInKb":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Vaults.Sum(v => v.FileSize))
|
||||
: query.OrderByDescending(x => x.Vaults.Sum(v => v.FileSize));
|
||||
break;
|
||||
case "TwoFactorEnabled":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.TwoFactorEnabled)
|
||||
: query.OrderByDescending(x => x.TwoFactorEnabled);
|
||||
break;
|
||||
case "LastVaultUpdate":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Vaults.Max(v => v.CreatedAt))
|
||||
: query.OrderByDescending(x => x.Vaults.Max(v => v.CreatedAt));
|
||||
break;
|
||||
default:
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Id)
|
||||
: query.OrderByDescending(x => x.Id);
|
||||
break;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
@page "/users/{Id}"
|
||||
|
||||
<LayoutPageTitle>User</LayoutPageTitle>
|
||||
|
||||
@if (IsLoading || User == null)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-cols-2 px-4 pt-6 md:grid-cols-3 lg:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems"/>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">View user</h1>
|
||||
<div class="flex">
|
||||
<a href="/users/@Id/delete" class="px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 focus:ring-4 focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800">
|
||||
Delete user
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center xl:block sm:space-x-4 xl:space-x-0 2xl:space-x-4">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">@User.UserName</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Id</label>
|
||||
<div>@User.Id</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">Vaults</h3>
|
||||
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Created</th>
|
||||
<th scope="col" class="px-4 py-3">Filesize</th>
|
||||
<th scope="col" class="px-4 py-3">DB version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in VaultList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">@entry.Id</td>
|
||||
<td class="px-4 py-3">@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">@Math.Round((double)entry.FileSize / 1024, 1) MB</td>
|
||||
<td class="px-4 py-3">@entry.Version</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">Email claims</h3>
|
||||
|
||||
<table class="w-full text-sm text-left text-gray-500 shadow rounded border">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">ID</th>
|
||||
<th scope="col" class="px-4 py-3">Created</th>
|
||||
<th scope="col" class="px-4 py-3">Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var entry in EmailClaimList)
|
||||
{
|
||||
<tr class="bg-white border-b hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">@entry.Id</td>
|
||||
<td class="px-4 py-3">@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="px-4 py-3">@entry.Address</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets the user ID.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private AliasVaultUser? User { get; set; } = new();
|
||||
private List<Vault> VaultList { get; set; } = new();
|
||||
private List<UserEmailClaim> EmailClaimList { get; set; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Users", Url = "/users" });
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View user" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
await LoadEntryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadEntryAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Load the aliases from the webapi via AliasService.
|
||||
User = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
if (User is null)
|
||||
{
|
||||
// Error loading user.
|
||||
GlobalNotificationService.AddErrorMessage("This user does not exist (anymore). Please try again.");
|
||||
NavigationService.RedirectTo("/users");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all vaults for this user (do not load the actual file content for performance reasons).
|
||||
VaultList = await DbContext.Vaults.Where(x => x.UserId == User.Id).Select(x => new Vault
|
||||
{
|
||||
Id = x.Id,
|
||||
Version = x.Version,
|
||||
FileSize = x.FileSize,
|
||||
CreatedAt = x.CreatedAt,
|
||||
})
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
// Load all email claims for this user.
|
||||
EmailClaimList = await DbContext.UserEmailClaims.Where(x => x.UserId == User.Id)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
|
||||
<SortableTable Columns="@_emailClaimTableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var entry in SortedEmailClaimList)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@entry.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.Address</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets the list of email claims to display.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<UserEmailClaim> EmailClaimList { get; set; } = [];
|
||||
|
||||
private string SortColumn { get; set; } = "CreatedAt";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
|
||||
private readonly List<TableColumn> _emailClaimTableColumns = [
|
||||
new TableColumn { Title = "ID", PropertyName = "Id" },
|
||||
new TableColumn { Title = "Created", PropertyName = "CreatedAt" },
|
||||
new TableColumn { Title = "Email", PropertyName = "Address" },
|
||||
];
|
||||
|
||||
private IEnumerable<UserEmailClaim> SortedEmailClaimList => SortList(EmailClaimList, SortColumn, SortDirection);
|
||||
|
||||
private void HandleSortChanged((string column, SortDirection direction) sort)
|
||||
{
|
||||
SortColumn = sort.column;
|
||||
SortDirection = sort.direction;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private static IEnumerable<UserEmailClaim> SortList(List<UserEmailClaim> emailClaims, string sortColumn, SortDirection sortDirection)
|
||||
{
|
||||
return sortColumn switch
|
||||
{
|
||||
"Id" => SortableTable.SortListByProperty(emailClaims, e => e.Id, sortDirection),
|
||||
"CreatedAt" => SortableTable.SortListByProperty(emailClaims, e => e.CreatedAt, sortDirection),
|
||||
"Address" => SortableTable.SortListByProperty(emailClaims, e => e.Address, sortDirection),
|
||||
_ => emailClaims
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
|
||||
<SortableTable Columns="@_refreshTokenTableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var entry in SortedRefreshTokenList)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@entry.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.DeviceIdentifier</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.IpAddress</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.ExpireDate.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
<Button Color="danger" OnClick="() => RevokeRefreshToken(entry)">Revoke</Button>
|
||||
</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets the list of refresh tokens to display.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<AliasVaultUserRefreshToken> RefreshTokenList { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the event callback to revoke a refresh token.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<AliasVaultUserRefreshToken> OnRevokeToken { get; set; }
|
||||
|
||||
private string SortColumn { get; set; } = "CreatedAt";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
|
||||
private readonly List<TableColumn> _refreshTokenTableColumns = [
|
||||
new TableColumn { Title = "ID", PropertyName = "Id" },
|
||||
new TableColumn { Title = "Device", PropertyName = "DeviceIdentifier" },
|
||||
new TableColumn { Title = "Ip", PropertyName = "IpAddress" },
|
||||
new TableColumn { Title = "Created", PropertyName = "CreatedAt" },
|
||||
new TableColumn { Title = "Expires", PropertyName = "ExpireDate" },
|
||||
new TableColumn { Title = "Actions", Sortable = false },
|
||||
];
|
||||
|
||||
private IEnumerable<AliasVaultUserRefreshToken> SortedRefreshTokenList => SortList(RefreshTokenList, SortColumn, SortDirection);
|
||||
|
||||
private void HandleSortChanged((string column, SortDirection direction) sort)
|
||||
{
|
||||
SortColumn = sort.column;
|
||||
SortDirection = sort.direction;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private static IEnumerable<AliasVaultUserRefreshToken> SortList(List<AliasVaultUserRefreshToken> refreshTokens, string sortColumn, SortDirection sortDirection)
|
||||
{
|
||||
return sortColumn switch
|
||||
{
|
||||
"Id" => SortableTable.SortListByProperty(refreshTokens, r => r.Id, sortDirection),
|
||||
"DeviceIdentifier" => SortableTable.SortListByProperty(refreshTokens, r => r.DeviceIdentifier, sortDirection),
|
||||
"IpAddress" => SortableTable.SortListByProperty(refreshTokens, r => r.IpAddress, sortDirection),
|
||||
"CreatedAt" => SortableTable.SortListByProperty(refreshTokens, r => r.CreatedAt, sortDirection),
|
||||
"ExpireDate" => SortableTable.SortListByProperty(refreshTokens, r => r.ExpireDate, sortDirection),
|
||||
_ => refreshTokens
|
||||
};
|
||||
}
|
||||
|
||||
private async Task RevokeRefreshToken(AliasVaultUserRefreshToken entry)
|
||||
{
|
||||
await OnRevokeToken.InvokeAsync(entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
|
||||
<SortableTable Columns="@_vaultTableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var entry in SortedVaultList)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@entry.Id</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.UpdatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
||||
<SortableTableColumn>@Math.Round((double)entry.FileSize / 1024, 1) MB</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.Version</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.RevisionNumber</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.CredentialsCount</SortableTableColumn>
|
||||
<SortableTableColumn>@entry.EmailClaimsCount</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
@if (entry == LatestVault)
|
||||
{
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300">Current</span>
|
||||
}
|
||||
@if (_previousEntry != null && HasPasswordChanged(entry, SortedVaultList))
|
||||
{
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-green-900 dark:text-green-300">Password Changed</span>
|
||||
}
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
@if (entry != LatestVault)
|
||||
{
|
||||
<Button OnClick="() => MakeCurrentAsync(entry)">Restore</Button>
|
||||
}
|
||||
</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
_previousEntry = entry;
|
||||
}
|
||||
</SortableTable>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets the list of vaults to display.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public List<Vault> VaultList { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the event callback to make a vault current.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<Vault> OnMakeCurrent { get; set; }
|
||||
|
||||
private string SortColumn { get; set; } = "RevisionNumber";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
private Vault? _previousEntry;
|
||||
|
||||
private readonly List<TableColumn> _vaultTableColumns = [
|
||||
new TableColumn { Title = "ID", PropertyName = "Id" },
|
||||
new TableColumn { Title = "Created", PropertyName = "CreatedAt" },
|
||||
new TableColumn { Title = "Updated", PropertyName = "UpdatedAt" },
|
||||
new TableColumn { Title = "Filesize", PropertyName = "FileSize" },
|
||||
new TableColumn { Title = "DB version", PropertyName = "Version" },
|
||||
new TableColumn { Title = "Revision", PropertyName = "RevisionNumber" },
|
||||
new TableColumn { Title = "Credentials", PropertyName = "CredentialsCount" },
|
||||
new TableColumn { Title = "Email Claims", PropertyName = "EmailClaimsCount" },
|
||||
new TableColumn { Title = "Status", Sortable = false },
|
||||
new TableColumn { Title = "Actions", Sortable = false },
|
||||
];
|
||||
|
||||
private IEnumerable<Vault> SortedVaultList => SortList(VaultList, SortColumn, SortDirection);
|
||||
|
||||
private Vault? LatestVault => VaultList.MaxBy(v => v.RevisionNumber);
|
||||
|
||||
private void HandleSortChanged((string column, SortDirection direction) sort)
|
||||
{
|
||||
SortColumn = sort.column;
|
||||
SortDirection = sort.direction;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private static IEnumerable<Vault> SortList(List<Vault> vaults, string sortColumn, SortDirection sortDirection)
|
||||
{
|
||||
return sortColumn switch
|
||||
{
|
||||
"Id" => SortableTable.SortListByProperty(vaults, v => v.Id, sortDirection),
|
||||
"CreatedAt" => SortableTable.SortListByProperty(vaults, v => v.CreatedAt, sortDirection),
|
||||
"UpdatedAt" => SortableTable.SortListByProperty(vaults, v => v.UpdatedAt, sortDirection),
|
||||
"FileSize" => SortableTable.SortListByProperty(vaults, v => v.FileSize, sortDirection),
|
||||
"Version" => SortableTable.SortListByProperty(vaults, v => v.Version, sortDirection),
|
||||
"RevisionNumber" => SortableTable.SortListByProperty(vaults, v => v.RevisionNumber, sortDirection),
|
||||
"CredentialsCount" => SortableTable.SortListByProperty(vaults, v => v.CredentialsCount, sortDirection),
|
||||
"EmailClaimsCount" => SortableTable.SortListByProperty(vaults, v => v.EmailClaimsCount, sortDirection),
|
||||
_ => vaults
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the password has changed between the current and previous vault entry based on revision number..
|
||||
/// </summary>
|
||||
private static bool HasPasswordChanged(Vault current, IEnumerable<Vault> vaultList)
|
||||
{
|
||||
// Get the previous vault entry to compare to based on revision number.
|
||||
var previousEntry = vaultList.FirstOrDefault(v => v.RevisionNumber == current.RevisionNumber - 1);
|
||||
|
||||
if (previousEntry == null)
|
||||
{
|
||||
// If the previous entry is null it means we have nothing to compare to so assume that it has not changed.
|
||||
return false;
|
||||
}
|
||||
|
||||
return current.Salt != previousEntry.Salt || current.Verifier != previousEntry.Verifier;
|
||||
}
|
||||
|
||||
private async Task MakeCurrentAsync(Vault vault)
|
||||
{
|
||||
await OnMakeCurrent.InvokeAsync(vault);
|
||||
}
|
||||
}
|
||||
283
src/AliasVault.Admin/Main/Pages/Users/View/Index.razor
Normal file
283
src/AliasVault.Admin/Main/Pages/Users/View/Index.razor
Normal file
@@ -0,0 +1,283 @@
|
||||
@page "/users/{Id}"
|
||||
@using AliasVault.Admin.Main.Pages.Users.View.Components
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>User</LayoutPageTitle>
|
||||
|
||||
@if (IsLoading || User == null)
|
||||
{
|
||||
<LoadingIndicator/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="View user"
|
||||
Description="View details of the user below.">
|
||||
<CustomActions>
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
<LinkButton Color="danger" Href="@($"/users/{Id}/delete")" Text="Delete user" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
<div class="grid grid-cols-2 px-4 pt-6 md:grid-cols-3 lg:gap-4 dark:bg-gray-900">
|
||||
<div class="col-span-full">
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center xl:block sm:space-x-4 xl:space-x-0 2xl:space-x-4">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">@User.UserName</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Id</label>
|
||||
<div class="text-gray-700 dark:text-gray-300">@User.Id</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">2FA Status:</span>
|
||||
<StatusPill Enabled="@User.TwoFactorEnabled"/>
|
||||
<span class="text-gray-700 dark:text-gray-300">Authenticator key(s) active: @TwoFactorKeysCount</span>
|
||||
@if (User.TwoFactorEnabled)
|
||||
{
|
||||
<Button Color="danger" OnClick="DisableTwoFactor">Disable 2FA</Button>
|
||||
}
|
||||
else
|
||||
{
|
||||
if (TwoFactorKeysCount > 0)
|
||||
{
|
||||
<Button Color="success" OnClick="EnableTwoFactor">Enable 2FA</Button>
|
||||
<Button Color="danger" OnClick="ResetTwoFactor">Remove 2FA keys</Button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">Vaults</h3>
|
||||
|
||||
<VaultTable VaultList="@VaultList" OnMakeCurrent="@MakeCurrentAsync" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">UserRefreshTokens (Logged in devices)</h3>
|
||||
|
||||
<RefreshTokenTable RefreshTokenList="@RefreshTokenList" OnRevokeToken="@RevokeRefreshToken" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="items-center">
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-bold text-gray-900 dark:text-white">Email claims</h3>
|
||||
|
||||
<EmailClaimTable EmailClaimList="@EmailClaimList" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets the user ID.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private AliasVaultUser? User { get; set; } = new();
|
||||
private int TwoFactorKeysCount { get; set; }
|
||||
private List<AliasVaultUserRefreshToken> RefreshTokenList { get; set; } = [];
|
||||
private List<Vault> VaultList { get; set; } = [];
|
||||
private List<UserEmailClaim> EmailClaimList { get; set; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Users", Url = "/users" });
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View user" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
if (firstRender)
|
||||
{
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Load the aliases from the webapi via AliasService.
|
||||
User = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
// Get count of user authenticator tokens.
|
||||
TwoFactorKeysCount = await DbContext.UserTokens.CountAsync(x => x.UserId == User!.Id && x.Name == "AuthenticatorKey");
|
||||
|
||||
if (User is null)
|
||||
{
|
||||
// Error loading user.
|
||||
GlobalNotificationService.AddErrorMessage("This user does not exist (anymore). Please try again.");
|
||||
NavigationService.RedirectTo("/users");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all active refresh tokens for this user to show which devices are logged in.
|
||||
RefreshTokenList = await DbContext.AliasVaultUserRefreshTokens.Where(x => x.UserId == User.Id).Select(x => new AliasVaultUserRefreshToken()
|
||||
{
|
||||
Id = x.Id,
|
||||
DeviceIdentifier = x.DeviceIdentifier,
|
||||
IpAddress = x.IpAddress,
|
||||
ExpireDate = x.ExpireDate,
|
||||
CreatedAt = x.CreatedAt,
|
||||
})
|
||||
.Where(x => x.ExpireDate > DateTime.UtcNow)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
// Load all vaults for this user (do not load the actual file content for performance reasons).
|
||||
VaultList = await DbContext.Vaults.Where(x => x.UserId == User.Id).Select(x => new Vault
|
||||
{
|
||||
Id = x.Id,
|
||||
Version = x.Version,
|
||||
RevisionNumber = x.RevisionNumber,
|
||||
FileSize = x.FileSize,
|
||||
CreatedAt = x.CreatedAt,
|
||||
UpdatedAt = x.UpdatedAt,
|
||||
Salt = x.Salt,
|
||||
Verifier = x.Verifier,
|
||||
VaultBlob = string.Empty,
|
||||
EncryptionType = x.EncryptionType,
|
||||
EncryptionSettings = x.EncryptionSettings,
|
||||
CredentialsCount = x.CredentialsCount,
|
||||
EmailClaimsCount = x.EmailClaimsCount,
|
||||
})
|
||||
.OrderBy(x => x.UpdatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
// Load all email claims for this user.
|
||||
EmailClaimList = await DbContext.UserEmailClaims.Where(x => x.UserId == User.Id)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method will revoke a refresh token for the user which will log out the device.
|
||||
/// </summary>
|
||||
private async Task RevokeRefreshToken(AliasVaultUserRefreshToken entry)
|
||||
{
|
||||
var token = await DbContext.AliasVaultUserRefreshTokens.FindAsync(entry.Id);
|
||||
|
||||
if (token != null)
|
||||
{
|
||||
DbContext.AliasVaultUserRefreshTokens.Remove(token);
|
||||
await DbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method enables two-factor authentication for the user based on existing keys. If no keys are present
|
||||
/// then 2FA will not work. The user will need to manually set up a new authenticator device.
|
||||
/// </summary>
|
||||
private async Task EnableTwoFactor()
|
||||
{
|
||||
User = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
if (User != null)
|
||||
{
|
||||
User.TwoFactorEnabled = true;
|
||||
await DbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method disables two-factor authentication for the user. This will NOT remove the authenticator keys.
|
||||
/// This means the admin can re-enable 2FA for the user without the user having to set up a new authenticator
|
||||
/// keys.
|
||||
/// </summary>
|
||||
private async Task DisableTwoFactor()
|
||||
{
|
||||
User = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
if (User != null)
|
||||
{
|
||||
User.TwoFactorEnabled = false;
|
||||
await DbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method resets the two-factor authentication for the user which will remove all authenticator keys. The
|
||||
/// next time the user enables two-factor authentication new keys will be generated. When keys are removed it
|
||||
/// also means 2FA cannot be re-enabled until the user manually sets up a new authenticator device.
|
||||
/// </summary>
|
||||
private async Task ResetTwoFactor()
|
||||
{
|
||||
User = await DbContext.AliasVaultUsers.FindAsync(Id);
|
||||
|
||||
if (User != null)
|
||||
{
|
||||
// Remove all authenticator keys and recovery codes.
|
||||
await DbContext.UserTokens
|
||||
.Where(x => x.UserId == User.Id && (x.Name == "AuthenticatorKey" || x.Name == "RecoveryCodes"))
|
||||
.ForEachAsync(x => DbContext.UserTokens.Remove(x));
|
||||
|
||||
await DbContext.SaveChangesAsync();
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the action of making a vault the current one.
|
||||
/// </summary>
|
||||
/// <param name="vault">The vault to make current.</param>
|
||||
private async Task MakeCurrentAsync(Vault vault)
|
||||
{
|
||||
if (await ConfirmModalService.ShowConfirmation(
|
||||
title: "Confirm Vault Restoration",
|
||||
message: @"Are you sure you want to restore this specific vault and make it the active one?
|
||||
|
||||
Important notes:
|
||||
• The next time the user logs in, they will load this vault version.
|
||||
• If the user has changed their password recently, their password will be reverted to the one associated with this vault.
|
||||
|
||||
Do you want to proceed with the restoration?")) {
|
||||
// Load vault
|
||||
var currentVault = await DbContext.Vaults.FindAsync(vault.Id);
|
||||
if (currentVault == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Update time to make this the current vault.
|
||||
currentVault.RevisionNumber = VaultList.MaxBy(x => x.RevisionNumber)!.RevisionNumber + 1;
|
||||
|
||||
// Save it.
|
||||
await DbContext.SaveChangesAsync();
|
||||
|
||||
// Reload the page.
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,11 @@
|
||||
@using AliasVault.Admin.Main.Components.Layout
|
||||
@using AliasVault.Admin.Main.Components.Loading
|
||||
@using AliasVault.Admin.Main.Components.WorkerStatus
|
||||
@using AliasVault.Admin.Main.Components.Refresh
|
||||
@using AliasVault.RazorComponents
|
||||
@using AliasVault.RazorComponents.Alerts
|
||||
@using AliasVault.RazorComponents.Buttons
|
||||
@using AliasVault.RazorComponents.Headings
|
||||
@using AliasVault.RazorComponents.Models
|
||||
@using AliasVault.Admin.Main.Models
|
||||
@using AliasVault.Admin.Main.Pages
|
||||
@using AliasVault.Admin.Services
|
||||
|
||||
@@ -5,19 +5,20 @@
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using AliasServerDb;
|
||||
using AliasServerDb.Configuration;
|
||||
using AliasVault.Admin;
|
||||
using AliasVault.Admin.Auth.Providers;
|
||||
using AliasVault.Admin.Main;
|
||||
using AliasVault.Admin.Services;
|
||||
using AliasVault.Auth;
|
||||
using AliasVault.Cryptography.Server;
|
||||
using AliasVault.Logging;
|
||||
using AliasVault.RazorComponents.Services;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -46,6 +47,9 @@ builder.Services.AddScoped<GlobalNotificationService>();
|
||||
builder.Services.AddScoped<GlobalLoadingService>();
|
||||
builder.Services.AddScoped<NavigationService>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingAuthenticationStateProvider>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<AuthLoggingService>();
|
||||
builder.Services.AddScoped<ConfirmModalService>();
|
||||
builder.Services.AddSingleton(new VersionedContentService(Directory.GetCurrentDirectory() + "/wwwroot"));
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
@@ -60,22 +64,7 @@ builder.Services.ConfigureApplicationCookie(options =>
|
||||
options.LoginPath = "/user/login";
|
||||
});
|
||||
|
||||
// We use dbContextFactory to create a new instance of the DbContext for every place that needs it
|
||||
// as otherwise concurrency issues may occur if we use a single instance of the DbContext across the application.
|
||||
builder.Services.AddSingleton<DbConnection>(container =>
|
||||
{
|
||||
var connection = new SqliteConnection(builder.Configuration.GetConnectionString("AliasServerDbContext"));
|
||||
connection.Open();
|
||||
|
||||
return connection;
|
||||
});
|
||||
|
||||
builder.Services.AddDbContextFactory<AliasServerDbContext>((container, options) =>
|
||||
{
|
||||
var connection = container.GetRequiredService<DbConnection>();
|
||||
options.UseSqlite(connection).UseLazyLoadingProxies();
|
||||
});
|
||||
|
||||
builder.Services.AddAliasVaultSqliteConfiguration();
|
||||
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
||||
builder.Services.AddIdentityCore<AdminUser>(options =>
|
||||
{
|
||||
@@ -87,12 +76,22 @@ builder.Services.AddIdentityCore<AdminUser>(options =>
|
||||
options.Password.RequiredUniqueChars = 0;
|
||||
options.SignIn.RequireConfirmedAccount = false;
|
||||
options.User.RequireUniqueEmail = false;
|
||||
options.Lockout.MaxFailedAccessAttempts = 10;
|
||||
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
|
||||
})
|
||||
.AddRoles<AdminRole>()
|
||||
.AddEntityFrameworkStores<AliasServerDbContext>()
|
||||
.AddSignInManager()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
builder.Services.AddAliasVaultDataProtection("AliasVault.Admin");
|
||||
|
||||
builder.Services.Configure<DataProtectionTokenProviderOptions>(options =>
|
||||
{
|
||||
options.TokenLifespan = TimeSpan.FromDays(30);
|
||||
options.Name = "AliasVault.Admin";
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
@@ -102,7 +101,6 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
@@ -116,7 +114,7 @@ app.MapRazorComponents<App>()
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var container = scope.ServiceProvider;
|
||||
var db = await container.GetRequiredService<IDbContextFactory<AliasServerDbContext>>().CreateDbContextAsync();
|
||||
await using var db = await container.GetRequiredService<IDbContextFactory<AliasServerDbContext>>().CreateDbContextAsync();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
await StartupTasks.CreateRolesIfNotExist(scope.ServiceProvider);
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ADMIN_PASSWORD_HASH": "AQAAAAIAAYagAAAAEKWfKfa2gh9Z72vjAlnNP1xlME7FsunRznzyrfqFte40FToufRwa3kX8wwDwnEXZag==",
|
||||
"ADMIN_PASSWORD_GENERATED": "2024-01-01T00:00:00Z"
|
||||
"ADMIN_PASSWORD_GENERATED": "2024-01-01T00:00:00Z",
|
||||
"DATA_PROTECTION_CERT_PASS": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
|
||||
@@ -22,12 +22,22 @@ public class GlobalNotificationService
|
||||
/// <summary>
|
||||
/// Gets or sets success messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
protected List<string> SuccessMessages { get; set; } = [];
|
||||
private List<string> SuccessMessages { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets info messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
private List<string> InfoMessages { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets warning messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
private List<string> WarningMessages { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets error messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
protected List<string> ErrorMessages { get; set; } = [];
|
||||
private List<string> ErrorMessages { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Adds a success message to the list of messages that should be displayed to the user.
|
||||
@@ -47,6 +57,42 @@ public class GlobalNotificationService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an info message to the list of messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to add.</param>
|
||||
/// <param name="notifyStateChanged">Whether to notify state change to subscribers. Defaults to false.
|
||||
/// Set this to true if you want to show the added message instantly instead of waiting for the notification
|
||||
/// display to rerender (e.g. after navigation).</param>
|
||||
public void AddInfoMessage(string message, bool notifyStateChanged = false)
|
||||
{
|
||||
InfoMessages.Add(message);
|
||||
|
||||
// Notify subscribers that a message has been added.
|
||||
if (notifyStateChanged)
|
||||
{
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a warning message to the list of messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to add.</param>
|
||||
/// <param name="notifyStateChanged">Whether to notify state change to subscribers. Defaults to false.
|
||||
/// Set this to true if you want to show the added message instantly instead of waiting for the notification
|
||||
/// display to rerender (e.g. after navigation).</param>
|
||||
public void AddWarningMessage(string message, bool notifyStateChanged = false)
|
||||
{
|
||||
WarningMessages.Add(message);
|
||||
|
||||
// Notify subscribers that a message has been added.
|
||||
if (notifyStateChanged)
|
||||
{
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an error message to the list of messages that should be displayed to the user.
|
||||
/// </summary>
|
||||
@@ -78,6 +124,16 @@ public class GlobalNotificationService
|
||||
messages.Add(new KeyValuePair<string, string>("success", message));
|
||||
}
|
||||
|
||||
foreach (var message in InfoMessages)
|
||||
{
|
||||
messages.Add(new KeyValuePair<string, string>("info", message));
|
||||
}
|
||||
|
||||
foreach (var message in WarningMessages)
|
||||
{
|
||||
messages.Add(new KeyValuePair<string, string>("warning", message));
|
||||
}
|
||||
|
||||
foreach (var message in ErrorMessages)
|
||||
{
|
||||
messages.Add(new KeyValuePair<string, string>("error", message));
|
||||
@@ -85,6 +141,7 @@ public class GlobalNotificationService
|
||||
|
||||
// Clear messages
|
||||
SuccessMessages.Clear();
|
||||
InfoMessages.Clear();
|
||||
ErrorMessages.Clear();
|
||||
|
||||
return messages;
|
||||
@@ -96,6 +153,8 @@ public class GlobalNotificationService
|
||||
public void ClearMessages()
|
||||
{
|
||||
SuccessMessages.Clear();
|
||||
InfoMessages.Clear();
|
||||
WarningMessages.Clear();
|
||||
ErrorMessages.Clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ public class UserService(AliasServerDbContext dbContext, UserManager<AdminUser>
|
||||
if (httpContextAccessor.HttpContext != null)
|
||||
{
|
||||
// Load user from database. Use a new context everytime to ensure we get the latest data.
|
||||
var userName = httpContextAccessor.HttpContext?.User?.Identity?.Name ?? string.Empty;
|
||||
var userName = httpContextAccessor.HttpContext?.User.Identity?.Name ?? string.Empty;
|
||||
|
||||
var user = await dbContext.AdminUsers.FirstOrDefaultAsync(u => u.UserName == userName);
|
||||
if (user != null)
|
||||
@@ -228,13 +228,29 @@ public class UserService(AliasServerDbContext dbContext, UserManager<AdminUser>
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if supplied password is correct for the user.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="password">The password to check.</param>
|
||||
/// <returns>Boolean indicating whether supplied password is valid and matches what is stored in the database.</returns>
|
||||
public async Task<bool> CheckPasswordAsync(AdminUser user, string password)
|
||||
{
|
||||
if (password.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await userManager.CheckPasswordAsync(user, password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update user roles. This is a separate method because it is called from both CreateUserAsync and UpdateUserAsync.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="roles">New roles for the user.</param>
|
||||
/// <returns>List of errors if any.</returns>
|
||||
public async Task<List<string>> UpdateUserRolesAsync(AdminUser user, List<string> roles)
|
||||
private async Task<List<string>> UpdateUserRolesAsync(AdminUser user, List<string> roles)
|
||||
{
|
||||
List<string> errors = new();
|
||||
|
||||
@@ -254,22 +270,6 @@ public class UserService(AliasServerDbContext dbContext, UserManager<AdminUser>
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if supplied password is correct for the user.
|
||||
/// </summary>
|
||||
/// <param name="user">User object.</param>
|
||||
/// <param name="password">The password to check.</param>
|
||||
/// <returns>Boolean indicating whether supplied password is valid and matches what is stored in the database..</returns>
|
||||
public async Task<bool> CheckPasswordAsync(AdminUser user, string password)
|
||||
{
|
||||
if (password.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await userManager.CheckPasswordAsync(user, password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate if user object contents conform to the requirements.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore.Server.Kestrel": "Error"
|
||||
}
|
||||
},
|
||||
"DetailedErrors": true
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore.Hosting.Diagnostics" : "Error",
|
||||
"Microsoft.AspNetCore.Server.Kestrel": "Error"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
|
||||
6
src/AliasVault.Admin/package-lock.json
generated
6
src/AliasVault.Admin/package-lock.json
generated
@@ -666,9 +666,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
|
||||
"integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "aliasvault.client",
|
||||
"name": "aliasvault.admin",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build:css": "tailwindcss -i ./tailwind.css -o ./wwwroot/css/tailwind.css --watch"
|
||||
"build:admin-css": "tailwindcss -i ./tailwind.css -o ./wwwroot/css/tailwind.css --watch"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@@ -3,7 +3,8 @@ module.exports = {
|
||||
content: [
|
||||
'./**/*.html',
|
||||
'./**/*.razor',
|
||||
'../Utilities/AliasVault.RazorComponents/**/*.razor',
|
||||
'../Shared/AliasVault.RazorComponents/**/*.razor',
|
||||
'../Shared/AliasVault.RazorComponents/**/*.cs',
|
||||
],
|
||||
safelist: [
|
||||
'w-64',
|
||||
|
||||
@@ -600,6 +600,10 @@ video {
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -620,6 +624,10 @@ video {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inset-0 {
|
||||
inset: 0px;
|
||||
}
|
||||
|
||||
.right-0 {
|
||||
right: 0px;
|
||||
}
|
||||
@@ -644,14 +652,6 @@ video {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.col-span-2 {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.col-span-6 {
|
||||
grid-column: span 6 / span 6;
|
||||
}
|
||||
|
||||
.mx-3 {
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
@@ -667,6 +667,10 @@ video {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@@ -675,18 +679,10 @@ video {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-5 {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
@@ -723,6 +719,10 @@ video {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.ms-1 {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.mt-0 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
@@ -731,6 +731,14 @@ video {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -743,10 +751,6 @@ video {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.line-clamp-1 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
@@ -790,6 +794,14 @@ video {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.h-20 {
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
.h-3 {
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.h-4 {
|
||||
height: 1rem;
|
||||
}
|
||||
@@ -822,10 +834,26 @@ video {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.w-1\/3 {
|
||||
width: 33.333333%;
|
||||
}
|
||||
|
||||
.w-10 {
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.w-2\/3 {
|
||||
width: 66.666667%;
|
||||
}
|
||||
|
||||
.w-20 {
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.w-3 {
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
}
|
||||
@@ -854,18 +882,14 @@ video {
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.w-96 {
|
||||
width: 24rem;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.w-1\/3 {
|
||||
width: 33.333333%;
|
||||
}
|
||||
|
||||
.w-2\/3 {
|
||||
width: 66.666667%;
|
||||
}
|
||||
|
||||
.max-w-2xl {
|
||||
max-width: 42rem;
|
||||
}
|
||||
@@ -882,10 +906,6 @@ video {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -924,6 +944,10 @@ video {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@@ -932,14 +956,6 @@ video {
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-cols-6 {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -972,10 +988,6 @@ video {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
|
||||
@@ -988,6 +1000,12 @@ video {
|
||||
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.space-x-3 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(0.75rem * var(--tw-space-x-reverse));
|
||||
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||
@@ -1035,6 +1053,11 @@ video {
|
||||
border-color: rgb(243 244 246 / var(--tw-divide-opacity));
|
||||
}
|
||||
|
||||
.divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-divide-opacity: 1;
|
||||
border-color: rgb(229 231 235 / var(--tw-divide-opacity));
|
||||
}
|
||||
|
||||
.self-center {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -1135,16 +1158,21 @@ video {
|
||||
border-color: rgb(234 179 8 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.bg-blue-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-blue-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 246 255 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-blue-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-blue-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
|
||||
@@ -1155,6 +1183,11 @@ video {
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-300 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-400 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
|
||||
@@ -1175,6 +1208,11 @@ video {
|
||||
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-700 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-800 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
|
||||
@@ -1185,6 +1223,11 @@ video {
|
||||
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-green-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-green-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(240 253 244 / var(--tw-bg-opacity));
|
||||
@@ -1200,6 +1243,11 @@ video {
|
||||
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-green-700 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-primary-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(253 222 133 / var(--tw-bg-opacity));
|
||||
@@ -1210,6 +1258,11 @@ video {
|
||||
background-color: rgb(251 203 116 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-primary-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(244 149 65 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-primary-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
|
||||
@@ -1240,6 +1293,11 @@ video {
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-red-700 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||
@@ -1250,6 +1308,11 @@ video {
|
||||
background-color: rgb(254 249 195 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-yellow-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 252 232 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-yellow-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||
@@ -1275,6 +1338,10 @@ video {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.p-5 {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.p-6 {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@@ -1284,6 +1351,11 @@ video {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.px-2\.5 {
|
||||
padding-left: 0.625rem;
|
||||
padding-right: 0.625rem;
|
||||
}
|
||||
|
||||
.px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
@@ -1304,11 +1376,26 @@ video {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.px-7 {
|
||||
padding-left: 1.75rem;
|
||||
padding-right: 1.75rem;
|
||||
}
|
||||
|
||||
.px-8 {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.py-0 {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.py-0\.5 {
|
||||
padding-top: 0.125rem;
|
||||
padding-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.py-1 {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
@@ -1344,6 +1431,14 @@ video {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.pl-2 {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.pr-2 {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.ps-2 {
|
||||
padding-inline-start: 0.5rem;
|
||||
}
|
||||
@@ -1360,14 +1455,6 @@ video {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.pl-2 {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.pr-2 {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
@@ -1381,6 +1468,11 @@ video {
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.text-5xl {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
@@ -1443,6 +1535,11 @@ video {
|
||||
color: rgb(37 99 235 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-blue-800 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(30 64 175 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-gray-200 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(229 231 235 / var(--tw-text-opacity));
|
||||
@@ -1458,6 +1555,11 @@ video {
|
||||
color: rgb(107 114 128 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(75 85 99 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-gray-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
||||
@@ -1518,11 +1620,6 @@ video {
|
||||
color: rgb(133 77 14 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-blue-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(59 130 246 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -1595,21 +1692,31 @@ video {
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
.hover\:bg-blue-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-100:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-400:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-50:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-800:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-green-800:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(22 101 52 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-primary-200:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(251 203 116 / var(--tw-bg-opacity));
|
||||
@@ -1630,11 +1737,6 @@ video {
|
||||
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-red-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-gray-900:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(17 24 39 / var(--tw-text-opacity));
|
||||
@@ -1686,11 +1788,6 @@ video {
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
|
||||
.focus\:ring-blue-300:focus {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-blue-500:focus {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
|
||||
@@ -1706,6 +1803,11 @@ video {
|
||||
--tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-green-300:focus {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(134 239 172 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-primary-300:focus {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(248 185 99 / var(--tw-ring-opacity));
|
||||
@@ -1726,15 +1828,25 @@ video {
|
||||
--tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-offset-2:focus {
|
||||
--tw-ring-offset-width: 2px;
|
||||
}
|
||||
|
||||
.dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-divide-opacity: 1;
|
||||
border-color: rgb(75 85 99 / var(--tw-divide-opacity));
|
||||
}
|
||||
|
||||
.dark\:divide-gray-700:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-divide-opacity: 1;
|
||||
border-color: rgb(55 65 81 / var(--tw-divide-opacity));
|
||||
}
|
||||
|
||||
.dark\:border:is(.dark *) {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.dark\:border-blue-500:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:border-gray-500:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(107 114 128 / var(--tw-border-opacity));
|
||||
@@ -1750,9 +1862,29 @@ video {
|
||||
border-color: rgb(55 65 81 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-blue-500:is(.dark *) {
|
||||
.dark\:border-green-500:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(34 197 94 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:border-red-500:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(239 68 68 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:border-yellow-500:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(234 179 8 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-blue-800:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
|
||||
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-blue-900:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(30 58 138 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-gray-600:is(.dark *) {
|
||||
@@ -1775,9 +1907,19 @@ video {
|
||||
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-primary-500:is(.dark *) {
|
||||
.dark\:bg-green-600:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(244 149 65 / var(--tw-bg-opacity));
|
||||
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-green-800:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(22 101 52 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-green-900:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(20 83 45 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-primary-600:is(.dark *) {
|
||||
@@ -1785,25 +1927,40 @@ video {
|
||||
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-red-600:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-red-800:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-red-900:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(127 29 29 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-yellow-800:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(133 77 14 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-yellow-900:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-red-500:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-opacity-80:is(.dark *) {
|
||||
--tw-bg-opacity: 0.8;
|
||||
}
|
||||
|
||||
.dark\:text-blue-300:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(147 197 253 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-blue-400:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||
@@ -1834,9 +1991,9 @@ video {
|
||||
color: rgb(75 85 99 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-green-400:is(.dark *) {
|
||||
.dark\:text-green-300:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(74 222 128 / var(--tw-text-opacity));
|
||||
color: rgb(134 239 172 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-primary-200:is(.dark *) {
|
||||
@@ -1893,11 +2050,6 @@ video {
|
||||
--tw-ring-offset-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-blue-600:hover:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-gray-600:hover:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
|
||||
@@ -1908,9 +2060,9 @@ video {
|
||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-primary-600:hover:is(.dark *) {
|
||||
.dark\:hover\:bg-green-700:hover:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
|
||||
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-primary-700:hover:is(.dark *) {
|
||||
@@ -1918,9 +2070,9 @@ video {
|
||||
background-color: rgb(184 112 47 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-red-600:hover:is(.dark *) {
|
||||
.dark\:hover\:bg-red-700:hover:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
|
||||
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-primary-500:hover:is(.dark *) {
|
||||
@@ -1948,11 +2100,6 @@ video {
|
||||
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-blue-800:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-gray-600:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity));
|
||||
@@ -1963,6 +2110,16 @@ video {
|
||||
--tw-ring-color: rgb(55 65 81 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-gray-800:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(31 41 55 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-green-800:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(22 101 52 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-primary-500:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(244 149 65 / var(--tw-ring-opacity));
|
||||
@@ -1978,21 +2135,12 @@ video {
|
||||
--tw-ring-color: rgb(154 93 38 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-red-900:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(127 29 29 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-red-800:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(153 27 27 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:col-span-3 {
|
||||
grid-column: span 3 / span 3;
|
||||
}
|
||||
|
||||
.sm\:flex {
|
||||
display: flex;
|
||||
}
|
||||
@@ -2011,18 +2159,18 @@ video {
|
||||
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.sm\:space-y-0 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(0px * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.sm\:space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.sm\:space-y-0 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(0px * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.sm\:p-6 {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@@ -2059,6 +2207,10 @@ video {
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
|
||||
.md\:inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.md\:flex {
|
||||
display: flex;
|
||||
}
|
||||
@@ -2116,14 +2268,6 @@ video {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.lg\:col-auto {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.lg\:mb-10 {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.lg\:mt-0 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
@@ -2180,17 +2324,17 @@ video {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.xl\:space-x-8 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(2rem * var(--tw-space-x-reverse));
|
||||
margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.xl\:space-x-0 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(0px * var(--tw-space-x-reverse));
|
||||
margin-left: calc(0px * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.xl\:space-x-8 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(2rem * var(--tw-space-x-reverse));
|
||||
margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
@@ -2198,10 +2342,6 @@ video {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.\32xl\:flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.\32xl\:space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
8
src/AliasVault.Admin/wwwroot/img/logo.svg
Normal file
8
src/AliasVault.Admin/wwwroot/img/logo.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg enable-background="new 0 0 500 500" version="1.1" viewBox="0 0 500 500" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m459.87 294.95c0.016205 5.4005 0.03241 10.801-0.35022 16.873-1.111 6.3392-1.1941 12.173-2.6351 17.649-10.922 41.508-36.731 69.481-77.351 83.408-7.2157 2.4739-14.972 3.3702-22.479 4.995-23.629 0.042205-47.257 0.11453-70.886 0.12027-46.762 0.011322-93.523-0.01416-140.95-0.43411-8.59-2.0024-16.766-2.8352-24.398-5.3326-21.595-7.0666-39.523-19.656-53.708-37.552-10.227-12.903-17.579-27.17-21.28-43.221-1.475-6.3967-2.4711-12.904-3.6852-19.361-0.051849-5.747-0.1037-11.494 0.26915-17.886 4.159-42.973 27.68-71.638 63.562-92.153 0-0.70761-0.001961-1.6988 3.12e-4 -2.69 0.022484-9.8293-1.3071-19.894 0.35664-29.438 3.2391-18.579 11.08-35.272 23.763-49.773 12.098-13.832 26.457-23.989 43.609-30.029 7.813-2.7512 16.14-4.0417 24.234-5.9948 7.392-0.025734 14.784-0.05146 22.835 0.32253 4.1959 0.95392 7.7946 1.2538 11.258 2.1053 17.16 4.2192 32.287 12.176 45.469 24.104 2.2558 2.0411 4.372 6.6241 9.621 3.868 16.839-8.8419 34.718-11.597 53.603-8.594 16.791 2.6699 31.602 9.4308 44.236 20.636 11.531 10.227 19.84 22.841 25.393 37.236 6.3436 16.445 10.389 33.163 6.0798 49.389 7.9587 8.9321 15.807 16.704 22.421 25.414 9.162 12.065 15.33 25.746 18.144 40.776 0.97046 5.1848 1.9111 10.375 2.8654 15.563m-71.597 71.012c5.5615-5.2284 12.002-9.7986 16.508-15.817 10.474-13.992 14.333-29.916 11.288-47.446-2.2496-12.95-8.1973-24.076-17.243-33.063-12.746-12.663-28.865-18.614-46.786-18.569-69.912 0.17712-139.82 0.56831-209.74 0.96176-15.922 0.089599-29.168 7.4209-39.685 18.296-14.45 14.944-20.408 33.343-16.655 54.368 2.2763 12.754 8.2167 23.748 17.158 32.66 13.299 13.255 30.097 18.653 48.728 18.651 59.321-0.005188 118.64 0.042358 177.96-0.046601 9.5912-0.014374 19.181-0.86588 28.773-0.88855 10.649-0.025146 19.978-3.825 29.687-9.1074z" fill="#EEC170"/>
|
||||
<path d="m162.77 293c15.654 4.3883 20.627 22.967 10.304 34.98-5.3104 6.1795-14.817 8.3208-24.278 5.0472-7.0723-2.4471-12.332-10.362-12.876-17.933-1.0451-14.542 11.089-23.176 21.705-23.046 1.5794 0.019287 3.1517 0.61566 5.1461 0.95184z" fill="#EEC170"/>
|
||||
<path d="m227.18 293.64c7.8499 2.3973 11.938 8.2143 13.524 15.077 1.8591 8.0439-0.44817 15.706-7.1588 21.121-6.7633 5.4572-14.417 6.8794-22.578 3.1483-8.2972-3.7933-12.836-10.849-12.736-19.438 0.1687-14.497 14.13-25.368 28.948-19.908z" fill="#EEC170"/>
|
||||
<path d="m261.57 319.07c-2.495-14.418 4.6853-22.603 14.596-26.108 9.8945-3.4995 23.181 3.4303 26.267 13.779 4.6504 15.591-7.1651 29.064-21.665 28.161-8.5254-0.53088-17.202-6.5094-19.198-15.831z" fill="#EEC170"/>
|
||||
<path d="m336.91 333.41c-9.0175-4.2491-15.337-14.349-13.829-21.682 3.0825-14.989 13.341-20.304 23.018-19.585 10.653 0.79141 17.93 7.407 19.765 17.547 1.9588 10.824-4.1171 19.939-13.494 23.703-5.272 2.1162-10.091 1.5086-15.46 0.017883z" fill="#EEC170"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB |
@@ -1,46 +1,35 @@
|
||||
function initializeDarkMode() {
|
||||
if (localStorage.getItem('color-theme') === 'dark' ||
|
||||
(!localStorage.getItem('color-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
function initDarkModeSwitcher() {
|
||||
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||||
const themeToggleBtn = document.getElementById('theme-toggle');
|
||||
|
||||
if (themeToggleDarkIcon === null && themeToggleLightIcon === null) {
|
||||
if (!themeToggleBtn || !themeToggleDarkIcon || !themeToggleLightIcon) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (localStorage.getItem('color-theme')) {
|
||||
if (localStorage.getItem('color-theme') === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
themeToggleLightIcon.classList.remove('hidden');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
themeToggleDarkIcon.classList.remove('hidden');
|
||||
}
|
||||
if (localStorage.getItem('color-theme') === 'dark' ||
|
||||
(!localStorage.getItem('color-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
themeToggleDarkIcon?.classList.remove('hidden');
|
||||
} else {
|
||||
themeToggleLightIcon?.classList.remove('hidden');
|
||||
}
|
||||
else {
|
||||
// Default to light mode if not set.
|
||||
document.documentElement.classList.remove('dark');
|
||||
themeToggleLightIcon.classList.remove('hidden');
|
||||
}
|
||||
|
||||
const themeToggleBtn = document.getElementById('theme-toggle');
|
||||
|
||||
let event = new Event('dark-mode');
|
||||
|
||||
themeToggleBtn.addEventListener('click', function () {
|
||||
// toggle icons
|
||||
themeToggleDarkIcon.classList.toggle('hidden');
|
||||
themeToggleLightIcon.classList.toggle('hidden');
|
||||
|
||||
// if set via local storage previously
|
||||
if (localStorage.getItem('color-theme')) {
|
||||
if (localStorage.getItem('color-theme') === 'light') {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
}
|
||||
// if NOT set via local storage previously
|
||||
} else if (document.documentElement.classList.contains('dark')) {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
} else {
|
||||
@@ -51,3 +40,25 @@ function initDarkModeSwitcher() {
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
window.initTopMenu = function() {
|
||||
initDarkModeSwitcher();
|
||||
};
|
||||
|
||||
function observeDOMChanges() {
|
||||
// Set up a mutation observer for the <body> element.
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (let mutation of mutations) {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||
initializeDarkMode();
|
||||
// Only needs to run once per batch of mutations.
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
initializeDarkMode();
|
||||
observeDOMChanges();
|
||||
|
||||
@@ -10,6 +10,44 @@ function downloadFileFromStream(fileName, contentStreamReference) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
window.initTopMenu = function() {
|
||||
initDarkModeSwitcher();
|
||||
};
|
||||
|
||||
window.registerClickOutsideHandler = (dotNetHelper) => {
|
||||
document.addEventListener('click', (event) => {
|
||||
const menu = document.getElementById('userMenuDropdown');
|
||||
const menuButton = document.getElementById('userMenuDropdownButton');
|
||||
if (menu && !menu.contains(event.target) && !menuButton.contains(event.target)) {
|
||||
dotNetHelper.invokeMethodAsync('CloseMenu');
|
||||
}
|
||||
|
||||
const mobileMenu = document.getElementById('mobileMenu');
|
||||
const mobileMenuButton = document.getElementById('toggleMobileMenuButton');
|
||||
if (mobileMenu && !mobileMenu.contains(event.target) && !mobileMenuButton.contains(event.target)) {
|
||||
dotNetHelper.invokeMethodAsync('CloseMenu');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.clipboardCopy = {
|
||||
copyText: function (text) {
|
||||
navigator.clipboard.writeText(text).then(function () { })
|
||||
.catch(function (error) {
|
||||
alert(error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.isFunctionDefined = function(functionName) {
|
||||
return typeof window[functionName] === 'function';
|
||||
};
|
||||
|
||||
// Primarily used by E2E tests.
|
||||
window.blazorNavigate = (url) => {
|
||||
Blazor.navigateTo(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a QR code for the given id element that has a data-url attribute.
|
||||
* @param id
|
||||
|
||||
@@ -21,27 +21,24 @@
|
||||
<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.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.2.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.2.0" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AliasGenerators\AliasGenerators.csproj" />
|
||||
<ProjectReference Include="..\AliasVault.Shared\AliasVault.Shared.csproj" />
|
||||
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
|
||||
<ProjectReference Include="..\Shared\AliasVault.Shared\AliasVault.Shared.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.Auth\AliasVault.Auth.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
|
||||
<ProjectReference Include="..\Utilities\Cryptography\Cryptography.csproj" />
|
||||
<ProjectReference Include="..\Utilities\FaviconExtractor\FaviconExtractor.csproj" />
|
||||
<ProjectReference Include="..\Utilities\Cryptography\AliasVault.Cryptography.Client\AliasVault.Cryptography.Client.csproj" />
|
||||
<ProjectReference Include="..\Utilities\Cryptography\AliasVault.Cryptography.Server\AliasVault.Cryptography.Server.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.FaviconExtractor\AliasVault.FaviconExtractor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Api.Controllers;
|
||||
namespace AliasVault.Api.Controllers.Abstracts;
|
||||
|
||||
using System.Security.Claims;
|
||||
using AliasServerDb;
|
||||
@@ -14,14 +14,20 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// Base controller for requests that require authentication.
|
||||
/// Base controller that concrete controllers can extend from if all requests require authentication.
|
||||
/// </summary>
|
||||
/// <param name="userManager">UserManager instance.</param>
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class AuthenticatedRequestController(UserManager<AliasVaultUser> userManager) : ControllerBase
|
||||
public abstract class AuthenticatedRequestController(UserManager<AliasVaultUser> userManager) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the userManager instance.
|
||||
/// </summary>
|
||||
/// <returns>UserManager instance.</returns>
|
||||
protected UserManager<AliasVaultUser> GetUserManager() => userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Get the current authenticated user.
|
||||
/// </summary>
|
||||
@@ -12,17 +12,22 @@ using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using AliasServerDb;
|
||||
using AliasVault.Shared.Models;
|
||||
using AliasVault.Api.Helpers;
|
||||
using AliasVault.Auth;
|
||||
using AliasVault.Cryptography.Client;
|
||||
using AliasVault.Shared.Models.Enums;
|
||||
using AliasVault.Shared.Models.WebApi;
|
||||
using AliasVault.Shared.Models.WebApi.Auth;
|
||||
using AliasVault.Shared.Models.WebApi.PasswordChange;
|
||||
using AliasVault.Shared.Providers.Time;
|
||||
using Asp.Versioning;
|
||||
using Cryptography.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SecureRemotePassword;
|
||||
|
||||
/// <summary>
|
||||
/// Auth controller for handling authentication.
|
||||
@@ -31,17 +36,49 @@ using Microsoft.IdentityModel.Tokens;
|
||||
/// <param name="userManager">UserManager instance.</param>
|
||||
/// <param name="signInManager">SignInManager instance.</param>
|
||||
/// <param name="configuration">IConfiguration instance.</param>
|
||||
/// <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>
|
||||
/// <param name="cache">IMemoryCache instance for persisting SRP values during multistep login process.</param>
|
||||
/// <param name="timeProvider">ITimeProvider instance. This returns the time which can be mutated for testing.</param>
|
||||
/// <param name="authLoggingService">AuthLoggingService instance. This is used to log auth attempts to the database.</param>
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[ApiController]
|
||||
[ApiVersion("1")]
|
||||
public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, SignInManager<AliasVaultUser> signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider) : ControllerBase
|
||||
public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, SignInManager<AliasVaultUser> signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider, AuthLoggingService authLoggingService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Error message for invalid email or password.
|
||||
/// Error message for invalid username or password.
|
||||
/// </summary>
|
||||
public static readonly string[] InvalidEmailOrPasswordError = ["Invalid email or password. Please try again."];
|
||||
private static readonly string[] InvalidUsernameOrPasswordError = ["Invalid username or password. Please try again."];
|
||||
|
||||
/// <summary>
|
||||
/// Error message for invalid 2-factor authentication code.
|
||||
/// </summary>
|
||||
private static readonly string[] Invalid2FaCode = ["Invalid authenticator code."];
|
||||
|
||||
/// <summary>
|
||||
/// Error message for invalid 2-factor authentication recovery code.
|
||||
/// </summary>
|
||||
private static readonly string[] InvalidRecoveryCode = ["Invalid recovery code."];
|
||||
|
||||
/// <summary>
|
||||
/// Error message for invalid 2-factor authentication recovery code.
|
||||
/// </summary>
|
||||
private static readonly string[] AccountLocked = ["You have entered an incorrect password too many times and your account has now been locked out. You can try again in 30 minutes.."];
|
||||
|
||||
/// <summary>
|
||||
/// Semaphore to prevent concurrent access to the database when generating new tokens for a user.
|
||||
/// </summary>
|
||||
private static readonly SemaphoreSlim Semaphore = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Status endpoint called by client to check if user is still authenticated.
|
||||
/// </summary>
|
||||
/// <returns>Returns OK if valid authentication is provided, otherwise it will return 401 unauthorized.</returns>
|
||||
[Authorize]
|
||||
[HttpGet("status")]
|
||||
public IActionResult Status()
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Login endpoint used to process login attempt using credentials.
|
||||
@@ -49,21 +86,32 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
/// <param name="model">Login model.</param>
|
||||
/// <returns>IActionResult.</returns>
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest model)
|
||||
public async Task<IActionResult> Login([FromBody] LoginInitiateRequest model)
|
||||
{
|
||||
var user = await userManager.FindByEmailAsync(model.Email);
|
||||
var user = await userManager.FindByNameAsync(model.Username);
|
||||
if (user == null)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidEmailOrPasswordError, 400));
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.Login, AuthFailureReason.InvalidUsername);
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400));
|
||||
}
|
||||
|
||||
// Check if the account is locked out.
|
||||
if (await userManager.IsLockedOutAsync(user))
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.TwoFactorAuthentication, AuthFailureReason.AccountLocked);
|
||||
return BadRequest(ServerValidationErrorResponse.Create(AccountLocked, 400));
|
||||
}
|
||||
|
||||
// Retrieve latest vault of user which contains the current salt and verifier.
|
||||
var latestVaultEncryptionSettings = AuthHelper.GetUserLatestVaultEncryptionSettings(user);
|
||||
|
||||
// Server creates ephemeral and sends to client
|
||||
var ephemeral = Cryptography.Srp.GenerateEphemeralServer(user.Verifier);
|
||||
var ephemeral = Srp.GenerateEphemeralServer(latestVaultEncryptionSettings.Verifier);
|
||||
|
||||
// Store the server ephemeral in memory cache for Validate() endpoint to use.
|
||||
cache.Set(model.Email, ephemeral.Secret, TimeSpan.FromMinutes(5));
|
||||
cache.Set(AuthHelper.CachePrefixEphemeral + model.Username, ephemeral.Secret, TimeSpan.FromMinutes(5));
|
||||
|
||||
return Ok(new LoginResponse(user.Salt, ephemeral.Public));
|
||||
return Ok(new LoginInitiateResponse(latestVaultEncryptionSettings.Salt, ephemeral.Public, latestVaultEncryptionSettings.EncryptionType, latestVaultEncryptionSettings.EncryptionSettings));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -74,37 +122,118 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
[HttpPost("validate")]
|
||||
public async Task<IActionResult> Validate([FromBody] ValidateLoginRequest model)
|
||||
{
|
||||
var user = await userManager.FindByEmailAsync(model.Email);
|
||||
if (user == null)
|
||||
var (user, serverSession, error) = await ValidateUserAndPassword(model);
|
||||
if (error is not null)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidEmailOrPasswordError, 400));
|
||||
// Error occured during validation, return the error.
|
||||
return error;
|
||||
}
|
||||
|
||||
if (!cache.TryGetValue(model.Email, out var serverSecretEphemeral) || serverSecretEphemeral is not string)
|
||||
await authLoggingService.LogAuthEventSuccessAsync(model.Username, AuthEventType.Login);
|
||||
|
||||
// If 2FA is required, return that status and no JWT token yet.
|
||||
if (user!.TwoFactorEnabled)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidEmailOrPasswordError, 400));
|
||||
return Ok(new ValidateLoginResponse(true, string.Empty, null));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var serverSession = Cryptography.Srp.DeriveSessionServer(
|
||||
serverSecretEphemeral.ToString() ?? string.Empty,
|
||||
model.ClientPublicEphemeral,
|
||||
user.Salt,
|
||||
model.Email,
|
||||
user.Verifier,
|
||||
model.ClientSessionProof);
|
||||
// If 2FA is not required, then it means the user is successfully authenticated at this point.
|
||||
|
||||
// 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);
|
||||
// Reset failed login attempts.
|
||||
await userManager.ResetAccessFailedCountAsync(user);
|
||||
|
||||
// Return server proof for optional client check and token.
|
||||
return Ok(new ValidateLoginResponse(serverSession.Proof, tokenModel));
|
||||
}
|
||||
catch
|
||||
var tokenModel = await GenerateNewTokensForUser(user, extendedLifetime: model.RememberMe);
|
||||
return Ok(new ValidateLoginResponse(false, serverSession!.Proof, tokenModel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate login including two-factor authentication code check.
|
||||
/// </summary>
|
||||
/// <param name="model">ValidateLoginRequest2Fa model.</param>
|
||||
/// <returns>Task.</returns>
|
||||
[HttpPost("validate-2fa")]
|
||||
public async Task<IActionResult> Validate2Fa([FromBody] ValidateLoginRequest2Fa model)
|
||||
{
|
||||
var (user, serverSession, error) = await ValidateUserAndPassword(model);
|
||||
if (error is not null)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidEmailOrPasswordError, 400));
|
||||
// Error occured during validation, return the error.
|
||||
return error;
|
||||
}
|
||||
|
||||
if (user == null || serverSession == null)
|
||||
{
|
||||
// Expected variables are not set, return generic error.
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400));
|
||||
}
|
||||
|
||||
// Verify 2-factor code.
|
||||
var verifyResult = await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, model.Code2Fa.ToString());
|
||||
if (!verifyResult)
|
||||
{
|
||||
// Increment failed login attempts in order to lock out the account when the limit is reached.
|
||||
await userManager.AccessFailedAsync(user);
|
||||
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.TwoFactorAuthentication, AuthFailureReason.InvalidTwoFactorCode);
|
||||
return BadRequest(ServerValidationErrorResponse.Create(Invalid2FaCode, 400));
|
||||
}
|
||||
|
||||
// Validation of 2-FA token is successful, user is authenticated.
|
||||
await authLoggingService.LogAuthEventSuccessAsync(model.Username, AuthEventType.TwoFactorAuthentication);
|
||||
|
||||
// Reset failed login attempts.
|
||||
await userManager.ResetAccessFailedCountAsync(user);
|
||||
|
||||
// Generate and return the JWT token.
|
||||
var tokenModel = await GenerateNewTokensForUser(user, extendedLifetime: model.RememberMe);
|
||||
return Ok(new ValidateLoginResponse(false, serverSession.Proof, tokenModel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate login including two-factor authentication recovery code check.
|
||||
/// </summary>
|
||||
/// <param name="model">ValidateLoginRequestRecoveryCode model.</param>
|
||||
/// <returns>Task.</returns>
|
||||
[HttpPost("validate-recovery-code")]
|
||||
public async Task<IActionResult> ValidateRecoveryCode([FromBody] ValidateLoginRequestRecoveryCode model)
|
||||
{
|
||||
var (user, serverSession, error) = await ValidateUserAndPassword(model);
|
||||
if (error is not null)
|
||||
{
|
||||
// Error occured during validation, return the error.
|
||||
return error;
|
||||
}
|
||||
|
||||
if (user == null || serverSession == null)
|
||||
{
|
||||
// Expected variables are not set, return generic error.
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400));
|
||||
}
|
||||
|
||||
// Sanitize recovery code.
|
||||
var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty).ToUpper();
|
||||
|
||||
// Attempt to redeem the recovery code
|
||||
var redeemResult = await userManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode);
|
||||
|
||||
if (!redeemResult.Succeeded)
|
||||
{
|
||||
// Increment failed login attempts in order to lock out the account when the limit is reached.
|
||||
await userManager.AccessFailedAsync(user);
|
||||
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.TwoFactorAuthentication, AuthFailureReason.InvalidRecoveryCode);
|
||||
return BadRequest(ServerValidationErrorResponse.Create(InvalidRecoveryCode, 400));
|
||||
}
|
||||
|
||||
// Recovery code is valid, user is authenticated.
|
||||
await authLoggingService.LogAuthEventSuccessAsync(model.Username, AuthEventType.TwoFactorAuthentication);
|
||||
|
||||
// Reset failed login attempts.
|
||||
await userManager.ResetAccessFailedCountAsync(user);
|
||||
|
||||
// Generate and return the JWT token.
|
||||
var tokenModel = await GenerateNewTokensForUser(user, extendedLifetime: model.RememberMe);
|
||||
return Ok(new ValidateLoginResponse(false, serverSession.Proof, tokenModel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -117,46 +246,32 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var principal = GetPrincipalFromExpiredToken(tokenModel.Token);
|
||||
var principal = GetPrincipalFromToken(tokenModel.Token);
|
||||
if (principal.FindFirst(ClaimTypes.NameIdentifier)?.Value == null)
|
||||
{
|
||||
return Unauthorized("User not found (email-1)");
|
||||
return Unauthorized("User not found (name-1)");
|
||||
}
|
||||
|
||||
var user = await userManager.FindByIdAsync(principal.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty);
|
||||
if (user == null)
|
||||
{
|
||||
return Unauthorized("User not found (email-2)");
|
||||
return Unauthorized("User not found (name-2)");
|
||||
}
|
||||
|
||||
// Check if the refresh token is valid.
|
||||
// Remove any existing refresh tokens for this user and device.
|
||||
var deviceIdentifier = GenerateDeviceIdentifier(Request);
|
||||
var existingToken = context.AliasVaultUserRefreshTokens.FirstOrDefault(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier);
|
||||
if (existingToken == null || existingToken.Value != tokenModel.RefreshToken || existingToken.ExpireDate < timeProvider.UtcNow)
|
||||
var existingToken = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.UserId == user.Id && t.Value == tokenModel.RefreshToken);
|
||||
if (existingToken == null || existingToken.ExpireDate < timeProvider.UtcNow)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TokenRefresh, AuthFailureReason.InvalidRefreshToken);
|
||||
return Unauthorized("Refresh token expired");
|
||||
}
|
||||
|
||||
// Remove the existing refresh token.
|
||||
context.AliasVaultUserRefreshTokens.Remove(existingToken);
|
||||
|
||||
// Generate a new refresh token to replace the old one.
|
||||
var newRefreshToken = GenerateRefreshToken();
|
||||
|
||||
// Add new refresh token.
|
||||
await context.AliasVaultUserRefreshTokens.AddAsync(new AliasVaultUserRefreshToken
|
||||
{
|
||||
UserId = user.Id,
|
||||
DeviceIdentifier = deviceIdentifier,
|
||||
Value = newRefreshToken,
|
||||
ExpireDate = timeProvider.UtcNow.AddDays(30),
|
||||
CreatedAt = timeProvider.UtcNow,
|
||||
});
|
||||
// Generate new tokens for the user.
|
||||
var token = await GenerateNewTokensForUser(user, existingToken);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var token = GenerateJwtToken(user);
|
||||
return Ok(new TokenModel() { Token = token, RefreshToken = newRefreshToken });
|
||||
await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.TokenRefresh);
|
||||
return Ok(new TokenModel() { Token = token.Token, RefreshToken = token.RefreshToken });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -169,23 +284,24 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var principal = GetPrincipalFromExpiredToken(model.Token);
|
||||
var principal = GetPrincipalFromToken(model.Token);
|
||||
if (principal.FindFirst(ClaimTypes.NameIdentifier)?.Value == null)
|
||||
{
|
||||
return Unauthorized("User not found (email-1)");
|
||||
return Unauthorized("User not found (name-1)");
|
||||
}
|
||||
|
||||
var user = await userManager.FindByIdAsync(principal.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty);
|
||||
if (user == null)
|
||||
{
|
||||
return Unauthorized("User not found (email-2)");
|
||||
return Unauthorized("User not found (name-2)");
|
||||
}
|
||||
|
||||
// Check if the refresh token is valid.
|
||||
var deviceIdentifier = GenerateDeviceIdentifier(Request);
|
||||
var existingToken = context.AliasVaultUserRefreshTokens.FirstOrDefault(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier);
|
||||
var existingToken = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier);
|
||||
if (existingToken == null || existingToken.Value != model.RefreshToken)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.Logout, AuthFailureReason.InvalidRefreshToken);
|
||||
return Unauthorized("Invalid refresh token");
|
||||
}
|
||||
|
||||
@@ -193,27 +309,57 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
context.AliasVaultUserRefreshTokens.Remove(existingToken);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.Logout);
|
||||
return Ok("Refresh token revoked successfully");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register endpoint used to register a new user.
|
||||
/// </summary>
|
||||
/// <param name="model">Register model.</param>
|
||||
/// <param name="model">Register request model.</param>
|
||||
/// <returns>IActionResult.</returns>
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] SrpSignup model)
|
||||
public async Task<IActionResult> Register([FromBody] RegisterRequest model)
|
||||
{
|
||||
var user = new AliasVaultUser { UserName = model.Email, Email = model.Email, Salt = model.Salt, Verifier = model.Verifier, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
// Validate the username.
|
||||
var (isValid, errorMessage) = ValidateUsername(model.Username);
|
||||
if (!isValid)
|
||||
{
|
||||
return BadRequest(ServerValidationErrorResponse.Create([errorMessage], 400));
|
||||
}
|
||||
|
||||
var user = new AliasVaultUser
|
||||
{
|
||||
UserName = model.Username,
|
||||
CreatedAt = timeProvider.UtcNow,
|
||||
UpdatedAt = timeProvider.UtcNow,
|
||||
PasswordChangedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
user.Vaults.Add(new AliasServerDb.Vault
|
||||
{
|
||||
VaultBlob = string.Empty,
|
||||
Version = "0.0.0",
|
||||
RevisionNumber = 0,
|
||||
Salt = model.Salt,
|
||||
Verifier = model.Verifier,
|
||||
EncryptionType = model.EncryptionType,
|
||||
EncryptionSettings = model.EncryptionSettings,
|
||||
CreatedAt = timeProvider.UtcNow,
|
||||
UpdatedAt = timeProvider.UtcNow,
|
||||
});
|
||||
|
||||
var result = await userManager.CreateAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await authLoggingService.LogAuthEventSuccessAsync(model.Username, AuthEventType.Register);
|
||||
|
||||
// When a user is registered, they are automatically signed in.
|
||||
await signInManager.SignInAsync(user, isPersistent: false);
|
||||
|
||||
// Return the token.
|
||||
var tokenModel = await GenerateNewTokensForUser(user);
|
||||
var tokenModel = await GenerateNewTokensForUser(user, extendedLifetime: true);
|
||||
return Ok(tokenModel);
|
||||
}
|
||||
|
||||
@@ -221,6 +367,126 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
return BadRequest(ServerValidationErrorResponse.Create(errors, 400));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Password change request is done by verifying the current password and then saving the new password via SRP.
|
||||
/// </summary>
|
||||
/// <remarks>The submit handler for the change password logic is in VaultController.UpdateChangePassword()
|
||||
/// because changing the password of the AliasVault user also requires a new vault encrypted with that same
|
||||
/// password in order for things to work properly.</remarks>
|
||||
/// <returns>Task.</returns>
|
||||
[HttpGet("change-password/initiate")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> InitiatePasswordChange()
|
||||
{
|
||||
var user = await userManager.GetUserAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound(ServerValidationErrorResponse.Create("User not found.", 404));
|
||||
}
|
||||
|
||||
// Retrieve latest vault of user which contains the current salt and verifier.
|
||||
var latestVaultEncryptionSettings = AuthHelper.GetUserLatestVaultEncryptionSettings(user);
|
||||
|
||||
// Server creates ephemeral and sends to client
|
||||
var ephemeral = Srp.GenerateEphemeralServer(latestVaultEncryptionSettings.Verifier);
|
||||
|
||||
// Store the server ephemeral in memory cache for the Vault update (and set new password) endpoint to use.
|
||||
cache.Set(AuthHelper.CachePrefixEphemeral + user.UserName!, ephemeral.Secret, TimeSpan.FromMinutes(5));
|
||||
|
||||
return Ok(new PasswordChangeInitiateResponse(latestVaultEncryptionSettings.Salt, ephemeral.Public, latestVaultEncryptionSettings.EncryptionType, latestVaultEncryptionSettings.EncryptionSettings));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate username endpoint used to check if a username is available.
|
||||
/// </summary>
|
||||
/// <param name="model">ValidateUsernameRequest model.</param>
|
||||
/// <returns>IActionResult.</returns>
|
||||
[HttpPost("validate-username")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ValidateUsername([FromBody] ValidateUsernameRequest model)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(model.Username))
|
||||
{
|
||||
return BadRequest("Username is required.");
|
||||
}
|
||||
|
||||
var normalizedUsername = NormalizeUsername(model.Username);
|
||||
var existingUser = await userManager.FindByNameAsync(normalizedUsername);
|
||||
|
||||
if (existingUser != null)
|
||||
{
|
||||
return BadRequest("Username is already in use.");
|
||||
}
|
||||
|
||||
// Validate the username
|
||||
var (isValid, errorMessage) = ValidateUsername(normalizedUsername);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
return BadRequest(errorMessage);
|
||||
}
|
||||
|
||||
return Ok("Username is available.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a username by trimming and lowercasing it.
|
||||
/// </summary>
|
||||
/// <param name="username">The username to normalize.</param>
|
||||
/// <returns>The normalized username.</returns>
|
||||
private static string NormalizeUsername(string username)
|
||||
{
|
||||
return username.ToLowerInvariant().Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates if a given username meets the required criteria.
|
||||
/// </summary>
|
||||
/// <param name="username">The username to validate.</param>
|
||||
/// <returns>A tuple containing a boolean indicating if the username is valid, and an error message if it's invalid.</returns>
|
||||
private static (bool IsValid, string ErrorMessage) ValidateUsername(string username)
|
||||
{
|
||||
const int minimumUsernameLength = 3;
|
||||
const string adminUsername = "admin";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
return (false, "Username cannot be empty or whitespace.");
|
||||
}
|
||||
|
||||
if (username.Length < minimumUsernameLength)
|
||||
{
|
||||
return (false, $"Username must be at least {minimumUsernameLength} characters long.");
|
||||
}
|
||||
|
||||
if (string.Equals(username, adminUsername, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (false, "Username 'admin' is not allowed.");
|
||||
}
|
||||
|
||||
// Check if it's a valid email address
|
||||
if (username.Contains('@'))
|
||||
{
|
||||
try
|
||||
{
|
||||
var addr = new System.Net.Mail.MailAddress(username);
|
||||
return (addr.Address == username, string.Empty);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (false, $"'{username}' is not a valid email address.");
|
||||
}
|
||||
}
|
||||
|
||||
// If it's not an email, check if it only contains letters and digits
|
||||
if (!username.All(char.IsLetterOrDigit))
|
||||
{
|
||||
return (false, $"Username '{username}' is invalid, can only contain letters or digits.");
|
||||
}
|
||||
|
||||
return (true, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a device identifier based on request headers. This is used to associate refresh tokens
|
||||
/// with a specific device for a specific user.
|
||||
@@ -240,6 +506,20 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
return rawIdentifier;
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
var randomNumber = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
|
||||
rng.GetBytes(randomNumber);
|
||||
return Convert.ToBase64String(randomNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the JWT key from the environment variables.
|
||||
/// </summary>
|
||||
@@ -257,12 +537,12 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the principal from an expired token. This is used to validate the token and extract the user.
|
||||
/// Get the principal from a token. This is used to validate the token and extract the user.
|
||||
/// </summary>
|
||||
/// <param name="token">The expired token as string.</param>
|
||||
/// <param name="token">The token as string.</param>
|
||||
/// <returns>Claims principal.</returns>
|
||||
/// <exception cref="SecurityTokenException">Thrown if provided token is invalid.</exception>
|
||||
private static ClaimsPrincipal GetPrincipalFromExpiredToken(string token)
|
||||
private static ClaimsPrincipal GetPrincipalFromToken(string token)
|
||||
{
|
||||
var tokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
@@ -270,6 +550,8 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
ValidateIssuer = false,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtKey())),
|
||||
|
||||
// We don't validate the token lifetime here, as we only use it for refresh tokens.
|
||||
ValidateLifetime = false,
|
||||
};
|
||||
|
||||
@@ -284,17 +566,39 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Validates the user and SRP session (password). If the user is not found or the password is invalid an
|
||||
/// action result is returned with the appropriate error message. If everything is valid nothing is returned.
|
||||
/// </summary>
|
||||
/// <returns>Random string to be used as refresh token.</returns>
|
||||
private static string GenerateRefreshToken()
|
||||
/// <param name="model">ValidateLoginRequest model.</param>
|
||||
/// <returns>User and SrpSession object if validation succeeded, IActionResult as error on error.</returns>
|
||||
private async Task<(AliasVaultUser? User, SrpSession? ServerSession, IActionResult? Error)> ValidateUserAndPassword(ValidateLoginRequest model)
|
||||
{
|
||||
var randomNumber = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
var user = await userManager.FindByNameAsync(model.Username);
|
||||
if (user == null)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(model.Username, AuthEventType.Login, AuthFailureReason.InvalidUsername);
|
||||
return (null, null, BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400)));
|
||||
}
|
||||
|
||||
rng.GetBytes(randomNumber);
|
||||
return Convert.ToBase64String(randomNumber);
|
||||
// Check if the account is locked out.
|
||||
if (await userManager.IsLockedOutAsync(user))
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TwoFactorAuthentication, AuthFailureReason.AccountLocked);
|
||||
return (null, null, BadRequest(ServerValidationErrorResponse.Create(AccountLocked, 400)));
|
||||
}
|
||||
|
||||
// Validate the SRP session (actual password check).
|
||||
var serverSession = AuthHelper.ValidateSrpSession(cache, user, model.ClientPublicEphemeral, model.ClientSessionProof);
|
||||
if (serverSession is null)
|
||||
{
|
||||
// Increment failed login attempts in order to lock out the account when the limit is reached.
|
||||
await userManager.AccessFailedAsync(user);
|
||||
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.Login, AuthFailureReason.InvalidPassword);
|
||||
return (null, null, BadRequest(ServerValidationErrorResponse.Create(InvalidUsernameOrPasswordError, 400)));
|
||||
}
|
||||
|
||||
return (user, serverSession, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -310,19 +614,18 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
{
|
||||
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 credentials = 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);
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
@@ -332,33 +635,115 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
|
||||
/// 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)
|
||||
/// <param name="extendedLifetime">If true, the refresh token will have an extended lifetime (remember me option).</param>
|
||||
private async Task<TokenModel> GenerateNewTokensForUser(AliasVaultUser user, bool extendedLifetime = false)
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var token = GenerateJwtToken(user);
|
||||
var refreshToken = GenerateRefreshToken();
|
||||
await Semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Determine the refresh token lifetime.
|
||||
// - 4 hours by default.
|
||||
// - 7 days if "remember me" was checked during login.
|
||||
var refreshTokenLifetime = TimeSpan.FromHours(4);
|
||||
if (extendedLifetime)
|
||||
{
|
||||
refreshTokenLifetime = TimeSpan.FromDays(7);
|
||||
}
|
||||
|
||||
// Generate device identifier
|
||||
// Return new refresh token.
|
||||
return await GenerateRefreshToken(user, refreshTokenLifetime);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="existingToken">The existing token that is being replaced (optional).</param>
|
||||
/// <returns>TokenModel which includes new access and refresh token.</returns>
|
||||
private async Task<TokenModel> GenerateNewTokensForUser(AliasVaultUser user, AliasVaultUserRefreshToken existingToken)
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
await Semaphore.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Token reuse window:
|
||||
// Check if a new refresh token was already generated for the current token in the last 30 seconds.
|
||||
// If yes, then return the already generated new token. This is to prevent client-side race conditions.
|
||||
var existingTokenReuseWindow = timeProvider.UtcNow.AddSeconds(-30);
|
||||
var existingTokenReuse = await context.AliasVaultUserRefreshTokens
|
||||
.FirstOrDefaultAsync(t => t.UserId == user.Id &&
|
||||
t.PreviousTokenValue == existingToken.Value &&
|
||||
t.CreatedAt > existingTokenReuseWindow);
|
||||
|
||||
if (existingTokenReuse is not null)
|
||||
{
|
||||
// A new token was already generated for the current token in the last 30 seconds.
|
||||
// Return the already generated new token.
|
||||
var accessToken = GenerateJwtToken(user);
|
||||
return new TokenModel { Token = accessToken, RefreshToken = existingTokenReuse.Value };
|
||||
}
|
||||
|
||||
// Remove the existing refresh token.
|
||||
var tokenToDelete = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.Id == existingToken.Id);
|
||||
if (tokenToDelete is null)
|
||||
{
|
||||
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TokenRefresh, AuthFailureReason.InvalidRefreshToken);
|
||||
throw new InvalidOperationException("Refresh token does not exist (anymore).");
|
||||
}
|
||||
|
||||
context.AliasVaultUserRefreshTokens.Remove(tokenToDelete);
|
||||
|
||||
// New refresh token lifetime is the same as the existing one.
|
||||
var existingTokenLifetime = existingToken.ExpireDate - existingToken.CreatedAt;
|
||||
|
||||
// Return new refresh token.
|
||||
return await GenerateRefreshToken(user, existingTokenLifetime, existingToken.Value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="newTokenLifetime">The lifetime of the new token.</param>
|
||||
/// <param name="existingTokenValue">The existing token value that is being replaced (optional).</param>
|
||||
/// <returns>TokenModel which includes new access and refresh token.</returns>
|
||||
private async Task<TokenModel> GenerateRefreshToken(AliasVaultUser user, TimeSpan newTokenLifetime, string? existingTokenValue = null)
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
// Generate device identifier
|
||||
var accessToken = GenerateJwtToken(user);
|
||||
var refreshToken = GenerateRefreshToken();
|
||||
var deviceIdentifier = GenerateDeviceIdentifier(Request);
|
||||
|
||||
// Save refresh token to database.
|
||||
// Remove any existing refresh tokens for this user and device.
|
||||
var existingTokens = context.AliasVaultUserRefreshTokens.Where(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier);
|
||||
context.AliasVaultUserRefreshTokens.RemoveRange(existingTokens);
|
||||
|
||||
// Add new refresh token.
|
||||
await context.AliasVaultUserRefreshTokens.AddAsync(new AliasVaultUserRefreshToken
|
||||
context.AliasVaultUserRefreshTokens.Add(new AliasVaultUserRefreshToken
|
||||
{
|
||||
UserId = user.Id,
|
||||
DeviceIdentifier = deviceIdentifier,
|
||||
IpAddress = IpAddressUtility.GetIpFromContext(HttpContext),
|
||||
Value = refreshToken,
|
||||
ExpireDate = timeProvider.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
PreviousTokenValue = existingTokenValue,
|
||||
ExpireDate = timeProvider.UtcNow.Add(newTokenLifetime),
|
||||
CreatedAt = timeProvider.UtcNow,
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return new TokenModel { Token = token, RefreshToken = refreshToken };
|
||||
await context.SaveChangesAsync();
|
||||
return new TokenModel { Token = accessToken, RefreshToken = refreshToken };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,20 @@
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Api.Controllers;
|
||||
namespace AliasVault.Api.Controllers.Email;
|
||||
|
||||
using AliasServerDb;
|
||||
using AliasVault.Api.Helpers;
|
||||
using AliasVault.Api.Controllers.Abstracts;
|
||||
using AliasVault.Shared.Models.Spamok;
|
||||
using AliasVault.Shared.Models.WebApi;
|
||||
using AliasVault.Shared.Models.WebApi.Email;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// Email controller for retrieving emails from the database.
|
||||
/// Email controller for retrieving emailboxes from the database.
|
||||
/// </summary>
|
||||
/// <param name="dbContextFactory">DbContext instance.</param>
|
||||
/// <param name="userManager">UserManager instance.</param>
|
||||
@@ -25,7 +26,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the newest version of the vault for the current user.
|
||||
/// Returns a list of emails for the provided email address.
|
||||
/// </summary>
|
||||
/// <param name="to">The full email address including @ sign.</param>
|
||||
/// <returns>List of aliases in JSON format.</returns>
|
||||
@@ -75,7 +76,7 @@ public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContex
|
||||
{
|
||||
Id = x.Id,
|
||||
Subject = x.Subject,
|
||||
FromDisplay = ConversionHelper.ConvertFromToFromDisplay(x.From),
|
||||
FromDisplay = x.From,
|
||||
FromDomain = x.FromDomain,
|
||||
FromLocal = x.FromLocal,
|
||||
ToDomain = x.ToDomain,
|
||||
@@ -91,10 +92,80 @@ public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContex
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
|
||||
MailboxApiModel returnValue = new MailboxApiModel();
|
||||
returnValue.Address = to;
|
||||
returnValue.Subscribed = false;
|
||||
returnValue.Mails = emails;
|
||||
var returnValue = new MailboxApiModel
|
||||
{
|
||||
Address = to,
|
||||
Subscribed = false,
|
||||
Mails = emails,
|
||||
};
|
||||
|
||||
return Ok(returnValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of emails for the provided email address.
|
||||
/// </summary>
|
||||
/// <param name="model">The request model extracted from POST body.</param>
|
||||
/// <returns>List of aliases in JSON format.</returns>
|
||||
[HttpPost(template: "bulk", Name = "GetEmailBoxBulk")]
|
||||
public async Task<IActionResult> GetEmailBoxBulk([FromBody] MailboxBulkRequest model)
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized("Not authenticated.");
|
||||
}
|
||||
|
||||
// Sanitize input.
|
||||
model.Addresses = model.Addresses.Select(x => x.Trim().ToLower()).ToList();
|
||||
model.PageSize = Math.Min(model.PageSize, 50);
|
||||
|
||||
// Load all email addresses that the user has a claim to where the address is in the list.
|
||||
var emailClaims = await context.UserEmailClaims
|
||||
.Where(claim => claim.UserId == user.Id && model.Addresses.Contains(claim.Address))
|
||||
.ToListAsync();
|
||||
|
||||
var query = context.Emails
|
||||
.AsNoTracking()
|
||||
.Include(x => x.EncryptionKey)
|
||||
.Where(email => context.UserEmailClaims
|
||||
.Any(claim => claim.UserId == user.Id
|
||||
&& claim.Address == email.To
|
||||
&& model.Addresses.Contains(claim.Address)));
|
||||
|
||||
var totalRecords = await query.CountAsync();
|
||||
|
||||
List<MailboxEmailApiModel> emails = await query.Select(x => new MailboxEmailApiModel
|
||||
{
|
||||
Id = x.Id,
|
||||
Subject = x.Subject,
|
||||
FromDisplay = x.From,
|
||||
FromDomain = x.FromDomain,
|
||||
FromLocal = x.FromLocal,
|
||||
ToDomain = x.ToDomain,
|
||||
ToLocal = x.ToLocal,
|
||||
Date = x.Date,
|
||||
DateSystem = x.DateSystem,
|
||||
SecondsAgo = (int)DateTime.UtcNow.Subtract(x.DateSystem).TotalSeconds,
|
||||
MessagePreview = x.MessagePreview ?? string.Empty,
|
||||
EncryptedSymmetricKey = x.EncryptedSymmetricKey,
|
||||
EncryptionKey = x.EncryptionKey.PublicKey,
|
||||
})
|
||||
.OrderByDescending(x => x.DateSystem)
|
||||
.Skip((model.Page - 1) * model.PageSize)
|
||||
.Take(model.PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
MailboxBulkResponse returnValue = new()
|
||||
{
|
||||
Addresses = emailClaims.Select(x => x.Address).ToList(),
|
||||
Mails = emails,
|
||||
PageSize = model.PageSize,
|
||||
CurrentPage = model.Page,
|
||||
TotalRecords = totalRecords,
|
||||
};
|
||||
|
||||
return Ok(returnValue);
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Api.Controllers;
|
||||
namespace AliasVault.Api.Controllers.Email;
|
||||
|
||||
using AliasServerDb;
|
||||
using AliasVault.Api.Helpers;
|
||||
using AliasVault.Api.Controllers.Abstracts;
|
||||
using AliasVault.Shared.Models.Spamok;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -18,10 +18,11 @@ using Microsoft.EntityFrameworkCore;
|
||||
/// <summary>
|
||||
/// Email controller for retrieving emails from the database.
|
||||
/// </summary>
|
||||
/// <param name="logger">ILogger instance.</param>
|
||||
/// <param name="dbContextFactory">DbContext instance.</param>
|
||||
/// <param name="userManager">UserManager instance.</param>
|
||||
[ApiVersion("1")]
|
||||
public class EmailController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
|
||||
public class EmailController(ILogger<VaultController> logger, IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the newest version of the vault for the current user.
|
||||
@@ -33,33 +34,16 @@ public class EmailController(IDbContextFactory<AliasServerDbContext> dbContextFa
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
if (user is null)
|
||||
var (email, errorResult) = await AuthenticateAndRetrieveEmailAsync(id, context);
|
||||
if (errorResult != null)
|
||||
{
|
||||
return Unauthorized("Not authenticated.");
|
||||
}
|
||||
|
||||
// Retrieve email from database.
|
||||
var email = await context.Emails.Include(x => x.EncryptionKey).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (email is null)
|
||||
{
|
||||
return NotFound("Email not found.");
|
||||
}
|
||||
|
||||
// See if this user has a valid claim to the email address.
|
||||
var emailClaim = await context.UserEmailClaims
|
||||
.FirstOrDefaultAsync(x => x.UserId == user.Id && x.Address == email.To);
|
||||
|
||||
if (emailClaim is null)
|
||||
{
|
||||
return Unauthorized("User does not have a claim to this email address.");
|
||||
return errorResult;
|
||||
}
|
||||
|
||||
var returnEmail = new EmailApiModel
|
||||
{
|
||||
Id = email.Id,
|
||||
Id = email!.Id,
|
||||
Subject = email.Subject,
|
||||
FromDisplay = ConversionHelper.ConvertFromToFromDisplay(email.From),
|
||||
FromDomain = email.FromDomain,
|
||||
FromLocal = email.FromLocal,
|
||||
ToDomain = email.ToDomain,
|
||||
@@ -85,12 +69,77 @@ public class EmailController(IDbContextFactory<AliasServerDbContext> dbContextFa
|
||||
|
||||
returnEmail.Attachments = attachments;
|
||||
|
||||
// Enrich HTML by changing all anchor tags to open in new tab
|
||||
if (returnEmail.MessageHtml != null && !string.IsNullOrEmpty(email.MessageHtml))
|
||||
{
|
||||
returnEmail.MessageHtml = ConversionHelper.ConvertAnchorTagsToOpenInNewTab(email.MessageHtml);
|
||||
}
|
||||
|
||||
return Ok(returnEmail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an email for the current user.
|
||||
/// </summary>
|
||||
/// <param name="id">The email ID to delete.</param>
|
||||
/// <returns>A response indicating the success or failure of the deletion.</returns>
|
||||
[HttpDelete(template: "{id}", Name = "DeleteEmail")]
|
||||
public async Task<IActionResult> DeleteEmail(int id)
|
||||
{
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var (email, errorResult) = await AuthenticateAndRetrieveEmailAsync(id, context);
|
||||
if (errorResult != null)
|
||||
{
|
||||
return errorResult;
|
||||
}
|
||||
|
||||
// Delete associated attachments
|
||||
context.EmailAttachments.RemoveRange(email!.Attachments);
|
||||
|
||||
// Delete the email
|
||||
context.Emails.Remove(email);
|
||||
|
||||
try
|
||||
{
|
||||
await context.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log the exception
|
||||
logger.LogError(ex, "An error occurred while deleting email with ID {id}.", id);
|
||||
return StatusCode(500, $"An error occurred while deleting the email: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates the user and retrieves the requested email.
|
||||
/// </summary>
|
||||
/// <param name="id">The email ID to retrieve.</param>
|
||||
/// <param name="context">The database context.</param>
|
||||
/// <returns>A tuple containing the authenticated user, the email, and an IActionResult if there's an error.</returns>
|
||||
private async Task<(Email? Email, IActionResult? ErrorResult)> AuthenticateAndRetrieveEmailAsync(int id, AliasServerDbContext context)
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return (null, Unauthorized("Not authenticated."));
|
||||
}
|
||||
|
||||
// Retrieve email from database.
|
||||
var email = await context.Emails
|
||||
.Include(x => x.Attachments)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (email is null)
|
||||
{
|
||||
return (null, NotFound("Email not found."));
|
||||
}
|
||||
|
||||
// See if this user has a valid claim to the email address.
|
||||
var emailClaim = await context.UserEmailClaims
|
||||
.FirstOrDefaultAsync(x => x.UserId == user.Id && x.Address == email.To);
|
||||
|
||||
if (emailClaim is null)
|
||||
{
|
||||
return (null, Unauthorized("User does not have a claim to this email address."));
|
||||
}
|
||||
|
||||
return (email, null);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user