Merge pull request #123 from lanedirt/113-add-blazor-server-admin-project-for-user-and-smtp-management

Add blazor server admin project for user and smtp management
This commit is contained in:
Leendert de Borst
2024-07-26 14:25:22 -07:00
committed by GitHub
169 changed files with 12974 additions and 721 deletions

View File

@@ -15,10 +15,10 @@ jobs:
options: --privileged
steps:
- uses: actions/checkout@v2
- name: Set permissions and run init.sh
- name: Set permissions and run install.sh
run: |
chmod +x init.sh
./init.sh
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
@@ -33,7 +33,7 @@ jobs:
# 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"
echo "Service did not respond with 200 OK. Check if client app is configured correctly."
exit 1
else
echo "Service responded with 200 OK"
@@ -43,7 +43,7 @@ jobs:
# 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 all DB migrations are applied."
echo "Service did not respond with expected 200 OK. Check if WebApi is configured correctly."
exit 1
else
echo "Service responded with $http_code"
@@ -57,3 +57,13 @@ jobs:
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

2
.gitignore vendored
View File

@@ -379,5 +379,5 @@ 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 init.sh and therefore should be ignored
# .env is generated by install.sh and therefore should be ignored
.env

View File

@@ -4,8 +4,9 @@
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/OGameX/releases)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/docker-compose-build.yml?label=docker-compose%20build">](https://github.com/lanedirt/AliasVault/actions/workflows/docker-compose-build.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-build-run-tests.yml?label=unit tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-build-run-tests.yml)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/dotnet-integration-tests.yml?label=e2e tests">](https://github.com/lanedirt/AliasVault/actions/workflows/dotnet-integration-tests.yml)
[<img src="https://img.shields.io/github/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/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>
@@ -39,32 +40,35 @@ To install AliasVault on your own machine, follow the steps below. Note: the ins
$ git clone https://github.com/lanedirt/AliasVault.git
```
### 2. Run the init script.
This script will create a .env file in the root directory of the project if it does not yet exist and populate it with a random encryption secret.
### 2. Run the install script.
The script checks and creates a .env file with a JWT secret, generates an admin password, and manages Docker image building and container initiation. It ensures necessary configurations and services are ready for the application's operation.
```bash
# Go to the project directory
$ cd AliasVault
# Make init script executable
$ chmod +x init.sh
# Make install script executable
$ chmod +x install.sh
# Run the init script and follow the steps
$ ./init.sh
# Run the install script
$ ./install.sh
```
### 3. Build and run the app via Docker:
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.
```bash
# Build and run the app via Docker Compose
$ docker compose up -d --build --force-recreate
```
> Note: the container binds to port 80 by default. If you have another service running on port 80, you can change the port in the `docker-compose.yml` file.
### 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.
> 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 for first time build:
- When running the docker compose command for the first time, it may take a few minutes to build the Docker image.
- 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.
- A SQLite database file will be created in `./database/AliasServerDb.sqlite`. This file will store all (encrypted) password vaults. It should be kept secure and not shared.
After the Docker containers have started the app will be available at http://localhost:80
#### Other useful commands:
- To reset the admin password, run the install.sh script with the `--reset-admin-password` flag.
- 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.
## Tech stack / credits
The following technologies, frameworks and libraries are used in this project:

View File

@@ -41,6 +41,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.SmtpService", "s
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.IntegrationTests", "src\Tests\AliasVault.IntegrationTests\AliasVault.IntegrationTests.csproj", "{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}"
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}"
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}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.WorkerStatus", "src\Utilities\AliasVault.WorkerStatus\AliasVault.WorkerStatus.csproj", "{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -103,6 +113,26 @@ Global
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.Build.0 = Release|Any CPU
{F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F2CAE93E-94A7-4365-8E84-8D48CE8DD53F}.Release|Any CPU.Build.0 = Release|Any CPU
{857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{857BCD0E-753F-437A-AF75-B995B4D9A5FE}.Release|Any CPU.Build.0 = Release|Any CPU
{FF0B0E64-1AE2-415C-A404-0EB78010821A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FF0B0E64-1AE2-415C-A404-0EB78010821A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF0B0E64-1AE2-415C-A404-0EB78010821A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF0B0E64-1AE2-415C-A404-0EB78010821A}.Release|Any CPU.Build.0 = Release|Any CPU
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC}.Release|Any CPU.Build.0 = Release|Any CPU
{951C3DF8-DF22-4B2B-839F-FBA26DDD8ABD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -119,6 +149,10 @@ Global
{A9C9A606-C87E-4298-AB32-09B1884D7487} = {01AB9389-2F89-4F8E-A688-BF4BF1FC42C8}
{B095A174-E528-4D38-BEC1-D1D38B3B30C0} = {8A477241-B96C-4174-968D-D40CB77F1ECD}
{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}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FEE82475-C009-4762-8113-A6563D9DC49E}

View File

@@ -1,4 +1,17 @@
services:
admin:
image: aliasvault-admin
build:
context: .
dockerfile: src/AliasVault.Admin/Dockerfile
ports:
- "8080:8082"
volumes:
- ./database:/database
- ./logs:/logs
restart: always
env_file:
- .env
client:
image: aliasvault-client
build:
@@ -19,6 +32,7 @@ services:
- "81:8081"
volumes:
- ./database:/database
- ./logs:/logs
env_file:
- .env
restart: always
@@ -33,6 +47,7 @@ services:
- "587:587"
volumes:
- ./database:/database
- ./logs:/logs
env_file:
- .env
restart: always

View File

@@ -0,0 +1,110 @@
# Manual Setup Instructions for AliasVault
This README provides step-by-step instructions for manually setting up AliasVault without using the `install.sh` script. Follow these steps if you prefer to execute all statements yourself.
## Prerequisites
- Docker and Docker Compose installed on your system
- OpenSSL for generating random passwords
## Steps
1. **Create .env file**
Copy the `.env.example` file to create a new `.env` file:
```
cp .env.example .env
```
2. **Generate and set JWT_KEY**
Update the .env file and set the JWT_KEY environment variable to a random 32-char string. This key is used for JWT token generation and should be kept secure.
Generate a random 32 char string for the JWT:
```
openssl rand -base64 32
```
Add the generated key to the .env file:
```
JWT_KEY=your_32_char_string_here
3. **Set SMTP_ALLOWED_DOMAINS**
Update the .env file and set the SMTP_ALLOWED_DOMAINS value the allowed domains that can be used for email addresses. Separate multiple domains with commas.
```
SMTP_ALLOWED_DOMAINS=yourdomain.com,anotherdomain.com
```
Replace `yourdomain.com,anotherdomain.com` with your actual allowed domains.
4. **Set SMTP_TLS_ENABLED**
Decide whether to enable TLS for email and add it to the .env file:
```
SMTP_TLS_ENABLED=true
```
Or set it to `false` if you don't want to enable TLS.
5. **Generate admin password**
Set the admin password hash in the .env file. The password hash is generated using the `InitializationCLI` utility.
Build the Docker image for password hashing:
```
docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile .
```
Generate the password hash:
```
docker run --rm initcli "<your_prefered_admin_password_here>"
```
Add the password hash and generation timestamp to the .env file:
```
ADMIN_PASSWORD_HASH=<output_of_step_above>
ADMIN_PASSWORD_GENERATED=2024-01-01T00:00:00Z
```
6. **Build and start Docker containers**
Build the Docker Compose stack:
```
docker-compose build
```
Start the Docker Compose stack:
```
docker-compose up -d
```
7. **Access AliasVault**
AliasVault should now be running. You can access it as follows:
- Admin Panel: http://localhost:8080/
- Username: admin
- Password: [Use the ADMIN_PASSWORD generated in step 5]
- Client Website: http://localhost:80/
- Create your own account from here
## Important Notes
- Make sure to save the admin password (ADMIN_PASSWORD) generated in step 5 in a secure location. It won't be shown again.
- If you need to reset the admin password in the future, you'll need to generate a new hash and update the .env file manually.
Afterwards restart the docker containers which will update the admin password in the database.
- Always keep your .env file secure and do not share it, as it contains sensitive information.
## Troubleshooting
If you encounter any issues during the setup:
1. Check the Docker logs:
```
docker-compose logs
```
2. Ensure all required ports (8080 and 80) are available and not being used by other services.
3. Verify that all environment variables in the .env file are set correctly.
For further assistance, please refer to the project documentation or seek support through the appropriate channels.

118
init.sh
View File

@@ -1,118 +0,0 @@
#!/bin/sh
# Define colors for CLI output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Define the path to the .env and .env.example files
ENV_FILE=".env"
ENV_EXAMPLE_FILE=".env.example"
# Function to generate a new 32-character JWT key
generate_jwt_key() {
dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | head -c 32
}
# Function to create .env file from .env.example if it doesn't exist
create_env_file() {
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$ENV_EXAMPLE_FILE" ]; then
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
printf "${GREEN}> .env file created from .env.example.${NC}\n"
else
touch "$ENV_FILE"
printf "${YELLOW}> .env file created as empty because .env.example was not found.${NC}\n"
fi
else
printf "${CYAN}> .env file already exists.${NC}\n"
fi
}
# Function to check and populate the .env file with JWT_KEY
populate_jwt_key() {
if ! grep -q "^JWT_KEY=" "$ENV_FILE" || [ -z "$(grep "^JWT_KEY=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
printf "${YELLOW}JWT_KEY not found or empty in $ENV_FILE. Generating a new JWT key...${NC}\n"
JWT_KEY=$(generate_jwt_key)
if grep -q "^JWT_KEY=" "$ENV_FILE"; then
awk -v key="$JWT_KEY" '/^JWT_KEY=/ {$0="JWT_KEY="key} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
else
printf "JWT_KEY=${JWT_KEY}" >> "$ENV_FILE\n"
fi
printf "${GREEN}> JWT_KEY has been added to $ENV_FILE.${NC}\n"
else
printf "${CYAN}> JWT_KEY already exists and has a value in $ENV_FILE.${NC}\n"
fi
}
# Function to ask the user for SMTP_ALLOWED_DOMAINS
set_smtp_allowed_domains() {
if ! grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
printf "${YELLOW}Please enter the domains that should be allowed to send email, separated by commas:${NC}\n"
read -r smtp_allowed_domains
if grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE"; then
awk -v domains="$smtp_allowed_domains" '/^SMTP_ALLOWED_DOMAINS=/ {$0="SMTP_ALLOWED_DOMAINS="domains} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
else
printf "SMTP_ALLOWED_DOMAINS=${smtp_allowed_domains}\n" >> "$ENV_FILE"
fi
printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set in $ENV_FILE.${NC}\n"
else
printf "${CYAN}> SMTP_ALLOWED_DOMAINS already exists and has a value in $ENV_FILE.${NC}\n"
fi
}
# Function to ask the user if TLS should be enabled for email
set_smtp_tls_enabled() {
if ! grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE" || [ -z "$(grep "^SMTP_TLS_ENABLED=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
printf "${YELLOW}Do you want TLS enabled for email? (yes/no):${NC}\n"
read -r tls_enabled
tls_enabled=$(echo "$tls_enabled" | tr '[:upper:]' '[:lower:]')
if [ "$tls_enabled" = "yes" ] || [ "$tls_enabled" = "y" ]; then
tls_enabled="true"
else
tls_enabled="false"
fi
if grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE"; then
awk -v tls="$tls_enabled" '/^SMTP_TLS_ENABLED=/ {$0="SMTP_TLS_ENABLED="tls} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
else
printf "SMTP_TLS_ENABLED=${tls_enabled}\n" >> "$ENV_FILE"
fi
printf "${GREEN}> SMTP_TLS_ENABLED has been set to ${tls_enabled} in $ENV_FILE.${NC}\n"
else
printf "${CYAN}> SMTP_TLS_ENABLED already exists and has a value in $ENV_FILE.${NC}\n"
fi
}
# Function to print the CLI logo
print_logo() {
printf "${MAGENTA}\n"
printf "=========================================================\n"
printf " _ _ __ __ _ _ \n"
printf " /\ | (_) \ \ / / | | | \n"
printf " / \ | |_ __ _ __\ \ / /_ _ _ _| | |_\n"
printf " / /\ \ | | |/ _ / __\ \/ / _ | | | | | __|\n"
printf " / ____ \| | | (_| \__ \\ / (_| | |_| | | |_ \n"
printf " /_/ \_\_|_|\__,_|___/ \/ \__,_|\__,_|_|\__|\n"
printf "\n"
printf "=========================================================\n"
printf "${NC}\n"
}
# Run the functions and print status
print_logo
printf "${BLUE}Initializing AliasVault...${NC}\n"
create_env_file
populate_jwt_key
set_smtp_allowed_domains
set_smtp_tls_enabled
printf "${BLUE}Initialization complete.${NC}\n"
printf "\n"
printf "To build the images and start the containers, run the following command:\n"
printf "\n"
printf "${CYAN}$ docker compose up -d --build --force-recreate${NC}\n"
printf "\n"
printf "\n"

326
install.sh Executable file
View File

@@ -0,0 +1,326 @@
#!/bin/sh
# Define colors for CLI output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Define the path to the .env and .env.example files
ENV_FILE=".env"
ENV_EXAMPLE_FILE=".env.example"
# Define verbose flag and reset password flag
VERBOSE=false
RESET_PASSWORD=false
# Function to parse command-line arguments
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
--verbose)
VERBOSE=true
;;
--reset-password)
RESET_PASSWORD=true
;;
*)
printf "${RED}Unknown argument: $1${NC}\n"
exit 1
;;
esac
shift
done
}
# Function to generate a random admin password and store its hash in the .env file
generate_admin_password() {
if grep -q "^ADMIN_PASSWORD_HASH=" ".env" && [ "$RESET_PASSWORD" = false ]; then
printf "${CYAN}> Checking admin password...${NC}\n"
printf "${GREEN}> ADMIN_PASSWORD_HASH already exists in .env. Use --reset-password to generate a new one.${NC}\n"
return 0
fi
printf "${CYAN}> Generating new admin password...${NC}\n"
ADMIN_PASSWORD=$(openssl rand -base64 12)
printf "${CYAN}> Building Docker image for password generation...${NC}"
if [ "$VERBOSE" = true ]; then
printf "\n"
docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile .
else
(
# Run docker build and capture its output
docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile . > install_build_output.log 2>&1 &
BUILD_PID=$!
printf "${CYAN}"
# Print dots while the build is running
while kill -0 $BUILD_PID 2>/dev/null; do
printf "."
sleep 1
done
printf "${NC}\n"
# Wait for the build to finish and capture its exit code
wait $BUILD_PID
BUILD_EXIT_CODE=$?
# If there was an error, display it
if [ $BUILD_EXIT_CODE -ne 0 ]; then
printf "\n${RED} An error occurred while building the Docker image for password generation. Check the output above.${NC}\n"
printf "\n"
cat install_build_output.log
exit $BUILD_EXIT_CODE
fi
)
fi
printf "${GREEN}> Docker image built successfully.${NC}\n"
printf "${CYAN}> Running Docker container to generate admin password hash...${NC}\n"
# Run the Docker container to generate the password hash
ADMIN_PASSWORD_HASH=$(docker run --rm initcli "$ADMIN_PASSWORD" 2> install_run_output.log)
RUN_EXIT_CODE=$?
if [ $RUN_EXIT_CODE -ne 0 ]; then
printf "${RED}> Error occurred while running the Docker container. Check install_run_output.log for details.${NC}\n"
return $RUN_EXIT_CODE
fi
# Remove existing ADMIN_PASSWORD_HASH and ADMIN_PASSWORD_GENERATED if it exists
sed -i '' '/^ADMIN_PASSWORD_HASH=/d' .env
sed -i '' '/^ADMIN_PASSWORD_GENERATED=/d' .env
# Append new entries
echo "ADMIN_PASSWORD_HASH=$ADMIN_PASSWORD_HASH" >> .env
echo "ADMIN_PASSWORD_GENERATED=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> .env
printf "${GREEN}> New admin password generated and hash stored in .env${NC}\n"
}
# Function to restart Docker containers
restart_docker_containers() {
printf "${CYAN}> Restarting Docker containers...${NC}\n"
docker-compose down
docker-compose up -d
printf "${GREEN}> Docker containers restarted successfully.${NC}\n"
}
# Function to generate a new 32-character JWT key
generate_jwt_key() {
dd if=/dev/urandom bs=1 count=32 2>/dev/null | base64 | head -c 32
}
# Function to create .env file from .env.example if it doesn't exist
create_env_file() {
printf "${CYAN}> Creating .env file...${NC}\n"
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$ENV_EXAMPLE_FILE" ]; then
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
printf "${GREEN}> .env file created from .env.example.${NC}\n"
else
touch "$ENV_FILE"
printf "${YELLOW}> .env file created as empty because .env.example was not found.${NC}\n"
fi
else
printf "${GREEN}> .env file already exists.${NC}\n"
fi
}
# Function to check and populate the .env file with JWT_KEY
populate_jwt_key() {
printf "${CYAN}> Checking JWT_KEY...${NC}\n"
if ! grep -q "^JWT_KEY=" "$ENV_FILE" || [ -z "$(grep "^JWT_KEY=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
JWT_KEY=$(generate_jwt_key)
if grep -q "^JWT_KEY=" "$ENV_FILE"; then
awk -v key="$JWT_KEY" '/^JWT_KEY=/ {$0="JWT_KEY="key} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
else
echo "JWT_KEY=${JWT_KEY}" >> "$ENV_FILE"
fi
printf "${GREEN}> JWT_KEY has been generated and added to $ENV_FILE.${NC}\n"
else
printf "${GREEN}> JWT_KEY already exists and has a value in $ENV_FILE.${NC}\n"
fi
}
# Function to ask the user for SMTP_ALLOWED_DOMAINS
set_smtp_allowed_domains() {
printf "${CYAN}> Setting SMTP_ALLOWED_DOMAINS...${NC}\n"
if ! grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
printf "Please enter the domains that should be allowed to send email, separated by commas: "
read -r smtp_allowed_domains
if grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE"; then
awk -v domains="$smtp_allowed_domains" '/^SMTP_ALLOWED_DOMAINS=/ {$0="SMTP_ALLOWED_DOMAINS="domains} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
else
echo "SMTP_ALLOWED_DOMAINS=${smtp_allowed_domains}" >> "$ENV_FILE"
fi
printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set in $ENV_FILE.${NC}\n"
else
printf "${GREEN}> SMTP_ALLOWED_DOMAINS already exists and has a value in $ENV_FILE.${NC}\n"
fi
}
# Function to ask the user if TLS should be enabled for email
set_smtp_tls_enabled() {
printf "${CYAN}> Setting SMTP_TLS_ENABLED...${NC}\n"
if ! grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE" || [ -z "$(grep "^SMTP_TLS_ENABLED=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
printf "Do you want TLS enabled for email? (yes/no): "
read -r tls_enabled
tls_enabled=$(echo "$tls_enabled" | tr '[:upper:]' '[:lower:]')
if [ "$tls_enabled" = "yes" ] || [ "$tls_enabled" = "y" ]; then
tls_enabled="true"
else
tls_enabled="false"
fi
if grep -q "^SMTP_TLS_ENABLED=" "$ENV_FILE"; then
awk -v tls="$tls_enabled" '/^SMTP_TLS_ENABLED=/ {$0="SMTP_TLS_ENABLED="tls} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
else
echo "SMTP_TLS_ENABLED=${tls_enabled}" >> "$ENV_FILE"
fi
printf "${GREEN}> SMTP_TLS_ENABLED has been set to ${tls_enabled} in $ENV_FILE.${NC}\n"
else
printf "${GREEN}> SMTP_TLS_ENABLED already exists and has a value in $ENV_FILE.${NC}\n"
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..."
if [ "$VERBOSE" = true ]; then
docker-compose build
else
(
# Run docker-compose build and capture its output
docker-compose build > install_compose_build_output.log 2>&1 &
BUILD_PID=$!
# Print dots while the build is running
while kill -0 $BUILD_PID 2>/dev/null; do
printf "."
sleep 1
done
printf "${NC}"
# Wait for the build to finish and capture its exit code
wait $BUILD_PID
BUILD_EXIT_CODE=$?
# If there was an error, display it
if [ $BUILD_EXIT_CODE -ne 0 ]; then
printf "\n${RED}> An error occurred while building the Docker Compose stack. Check install_compose_build_output.log for details.${NC}\n"
exit $BUILD_EXIT_CODE
fi
)
fi
printf "\n${GREEN}> Docker Compose stack built successfully.${NC}\n"
printf "${CYAN}> Starting Docker Compose stack...${NC}\n"
if [ "$VERBOSE" = true ]; then
docker-compose up -d
else
docker-compose up -d > install_compose_up_output.log 2>&1
fi
UP_EXIT_CODE=$?
if [ $UP_EXIT_CODE -ne 0 ]; then
printf "${RED}> An error occurred while starting the Docker Compose stack. Check install_compose_up_output.log for details.${NC}\n"
exit $UP_EXIT_CODE
fi
printf "${GREEN}> Docker Compose stack started successfully.${NC}\n"
}
# Function to print the CLI logo
print_logo() {
printf "${MAGENTA}\n"
printf "=========================================================\n"
printf " _ _ __ __ _ _ \n"
printf " /\ | (_) \ \ / / | | | \n"
printf " / \ | |_ __ _ __\ \ / /_ _ _ _| | |_\n"
printf " / /\ \ | | |/ _ / __\ \/ / _ | | | | | __|\n"
printf " / ____ \| | | (_| \__ \\ / (_| | |_| | | |_ \n"
printf " /_/ \_\_|_|\__,_|___/ \/ \__,_|\__,_|_|\__|\n"
printf "\n"
printf " Install Script\n"
printf "=========================================================\n"
printf "${NC}\n"
}
# Main execution flow
main() {
parse_args "$@"
if [ "$RESET_PASSWORD" = true ]; then
print_logo
generate_admin_password
if [ $? -eq 0 ]; then
restart_docker_containers
fi
printf "\n"
printf "${MAGENTA}=========================================================${NC}\n"
printf "\n"
printf "${GREEN}The admin password is successfully reset!${NC}\n"
printf "\n"
printf "${MAGENTA}=========================================================${NC}\n"
printf "\n"
else
# Run the original initialization process
print_logo
printf "${YELLOW}+++ Initializing .env file +++${NC}\n"
printf "\n"
create_env_file || exit $?
populate_jwt_key || exit $?
set_smtp_allowed_domains || exit $?
set_smtp_tls_enabled || exit $?
generate_admin_password || exit $?
printf "\n${YELLOW}+++ Building Docker containers +++${NC}\n"
printf "\n"
build_and_run_docker_compose || exit $?
printf "\n"
printf "${MAGENTA}=========================================================${NC}\n"
printf "\n"
printf "${GREEN}AliasVault is successfully installed!${NC}\n"
printf "\n"
printf "${MAGENTA}=========================================================${NC}\n"
printf "\n"
fi
printf "${CYAN}To configure the server, login to the admin panel:${NC}\n"
printf "\n"
if [ "$ADMIN_PASSWORD" != "" ]; then
printf "Admin Panel: http://localhost:8080/\n"
printf "Username: admin\n"
printf "Password: $ADMIN_PASSWORD\n"
printf "\n"
printf "${YELLOW}(!) Caution: Make sure to backup the above credentials in a safe place, they won't be shown again!${NC}\n"
printf "\n"
else
printf "Admin Panel: http://localhost:8080/\n"
printf "Username: admin\n"
printf "Password: (Previously set. Run this command with --reset-password to generate a new one.)\n"
printf "\n"
fi
printf "${CYAN}===========================${NC}\n"
printf "\n"
printf "${CYAN}In order to start using AliasVault and create your own vault, log into the client website:${NC}\n"
printf "\n"
printf "Client Website: http://localhost:80/\n"
printf "You can create your own account from there.\n"
printf "\n"
printf "${MAGENTA}=========================================================${NC}\n"
}
# Run the main function
main "$@"

View File

@@ -4,7 +4,7 @@
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasGenerators.Implementations;
namespace AliasGenerators.Password;
/// <summary>
/// Interface for password generators.

View File

@@ -4,9 +4,10 @@
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasGenerators.Password.Implementations;
using AliasGenerators.Implementations;
using AliasGenerators.Password;
/// <summary>
/// Implementation of IPasswordGenerator which generates passwords using the SpamOK library.

View File

@@ -0,0 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>aspnet-AliasVault.Admin-1DAADE35-C01B-43BB-B440-AA5E1E0B672D</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DocumentationFile>bin\Debug\net8.0\AliasVault.Admin.xml</DocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DocumentationFile>bin\Release\net8.0\AliasVault.Admin.xml</DocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</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">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="..\stylecop.json" Link="stylecop.json" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,41 @@
@using System.Linq.Expressions
<InputText @attributes="AdditionalAttributes"
id="@Id"
Value="@Value"
ValueChanged="ValueChanged"
ValueExpression="ValueExpression"
placeholder="@Placeholder"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" />
@code {
/// <summary>
/// Gets or sets the ID of the input field.
/// </summary>
[Parameter] public string Id { get; set; } = null!;
/// <summary>
/// Gets or sets the value of the input field.
/// </summary>
[Parameter] public string Value { get; set; } = null!;
/// <summary>
/// Gets or sets the event callback that is triggered when the value changes.
/// </summary>
[Parameter] public 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!;
/// <summary>
/// Gets or sets the placeholder text for the input field.
/// </summary>
[Parameter] public string Placeholder { get; set; } = null!;
/// <summary>
/// Gets or sets additional attributes for the input field.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object?>? AdditionalAttributes { get; set; } = new();
}

View File

@@ -0,0 +1,5 @@
<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>

View File

@@ -0,0 +1,36 @@
@inherits LayoutComponentBase
@using AliasVault.Admin.Auth.Components
@implements IDisposable
@inject NavigationManager NavigationManager
<div class="flex flex-col items-center justify-center px-6 pt-8 mx-auto md:h-screen pt:mt-0 dark:bg-gray-900">
<Logo />
<div class="w-full max-w-xl p-6 space-y-4 sm:p-8 bg-white rounded-lg shadow dark:bg-gray-800">
@Body
</div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@code {
/// <inheritdoc />
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
}
/// <inheritdoc />
protected override void OnInitialized()
{
NavigationManager.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
StateHasChanged();
}
}

View File

@@ -0,0 +1,18 @@
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,71 @@
//-----------------------------------------------------------------------
// <copyright file="AuthBase.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Auth.Pages;
using AliasServerDb;
using AliasVault.Admin.Main.Components.Alerts;
using AliasVault.Admin.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
/// <summary>
/// Base auth page that all pages that are part of the auth (non-logged in part of website) should inherit from.
/// All pages that inherit from this class will require the user to be logged out. If user is logged in they
/// are automatically redirected to index page.
/// </summary>
public class AuthBase : OwningComponentBase
{
/// <summary>
/// Gets or sets the logger.
/// </summary>
[Inject]
protected ILogger<Login> Logger { get; set; } = null!;
/// <summary>
/// Gets or sets the navigation service.
/// </summary>
[Inject]
protected NavigationService NavigationService { get; set; } = null!;
/// <summary>
/// Gets or sets the sign in manager.
/// </summary>
[Inject]
protected SignInManager<AdminUser> SignInManager { get; set; } = null!;
/// <summary>
/// Gets or sets the user manager.
/// </summary>
[Inject]
protected UserManager<AdminUser> UserManager { get; set; } = null!;
/// <summary>
/// Gets or sets the authentication state provider.
/// </summary>
[Inject]
protected AuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!;
/// <summary>
/// Gets or sets object which holds server validation errors to show in the UI.
/// </summary>
protected ServerValidationErrors ServerValidationErrors { get; set; } = new();
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
// Redirect to home if the user is already authenticated
if (SignInManager.IsSignedIn(user))
{
NavigationService.RedirectTo("/");
}
}
}

View File

@@ -0,0 +1,8 @@
@page "/user/forgot-password"
<LayoutPageTitle>Forgot your password?</LayoutPageTitle>
<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>

View File

@@ -0,0 +1,8 @@
@page "/user/lockout"
<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>

View File

@@ -0,0 +1,97 @@
@page "/user/login"
<LayoutPageTitle>Log in</LayoutPageTitle>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Sign in to AliasVault Admin
</h2>
<ServerValidationErrors @ref="ServerValidationErrors" />
<EditForm Model="Input" FormName="LoginForm" OnValidSubmit="LoginUser" class="mt-8 space-y-6">
<DataAnnotationsValidator/>
<div>
<label asp-for="Input.UserName" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your username</label>
<InputTextField id="username" @bind-Value="Input.UserName" placeholder="username" />
<ValidationMessage For="() => Input.UserName"/>
</div>
<div>
<label asp-for="Input.Password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Your password</label>
<InputTextField id="password" @bind-Value="Input.Password" type="password" placeholder="••••••••" />
<ValidationMessage For="() => Input.Password"/>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input id="remember" aria-describedby="remember" name="remember" type="checkbox" class="w-4 h-4 border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:focus:ring-primary-600 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600">
</div>
<div class="ml-3 text-sm">
<label for="remember" class="font-medium text-gray-900 dark:text-white">Remember me</label>
</div>
<a href="/user/forgot-password" class="ml-auto text-sm text-primary-700 hover:underline dark:text-primary-500">Lost Password?</a>
</div>
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white bg-primary-700 rounded-lg hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Login to your account</button>
</EditForm>
@code {
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
if (HttpMethods.IsGet(HttpContext.Request.Method))
{
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
}
}
/// <summary>
/// Logs in the user.
/// </summary>
protected async Task LoginUser()
{
ServerValidationErrors.Clear();
var result = await SignInManager.PasswordSignInAsync(Input.UserName, Input.Password, Input.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
Logger.LogInformation("User logged in.");
NavigationService.RedirectTo(ReturnUrl ?? "/");
}
else if (result.RequiresTwoFactor)
{
NavigationService.RedirectTo(
"user/loginWith2fa",
new Dictionary<string, object?> { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });
}
else if (result.IsLockedOut)
{
Logger.LogWarning("User account locked out.");
NavigationService.RedirectTo("user/lockout");
}
else
{
ServerValidationErrors.AddError("Error: Invalid login attempt.");
}
}
private sealed class InputModel
{
[Required] public string UserName { get; set; } = "";
[Required]
[DataType(DataType.Password)]
public string Password { get; set; } = "";
[Display(Name = "Remember me?")] public bool RememberMe { get; set; }
}
}

View File

@@ -0,0 +1,98 @@
@page "/user/loginWith2fa"
<LayoutPageTitle>Two-factor authentication</LayoutPageTitle>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Two-factor authentication
</h2>
<ServerValidationErrors @ref="ServerValidationErrors" />
<p class="text-gray-700 dark:text-gray-300 mb-6">Your login is protected with an authenticator app. Enter your authenticator code below.</p>
<div class="w-full max-w-md">
<EditForm Model="Input" FormName="login-with-2fa" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
<input type="hidden" name="ReturnUrl" value="@ReturnUrl"/>
<input type="hidden" name="RememberMe" value="@RememberMe"/>
<DataAnnotationsValidator/>
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
<div>
<label for="two-factor-code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authenticator code</label>
<InputText @bind-Value="Input.TwoFactorCode" id="two-factor-code" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" autocomplete="off"/>
<ValidationMessage For="() => Input.TwoFactorCode" class="text-red-600 dark:text-red-400 text-sm mt-1"/>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<InputCheckbox @bind-Value="Input.RememberMachine" id="remember-machine" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-primary-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-primary-600 dark:ring-offset-gray-800"/>
</div>
<div class="ml-3 text-sm">
<label for="remember-machine" class="font-medium text-gray-900 dark:text-white">Remember this machine</label>
</div>
</div>
<button type="submit" class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Log in</button>
</EditForm>
</div>
<p class="mt-6 text-sm text-gray-700 dark:text-gray-300">
Don't have access to your authenticator device? You can
<a href="user/loginWithRecoveryCode?ReturnUrl=@ReturnUrl" class="text-primary-600 hover:underline dark:text-primary-500">log in with a recovery code</a>.
</p>
@code {
private AdminUser user = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
[SupplyParameterFromQuery] private bool RememberMe { get; set; }
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Ensure the user has gone through the username & password screen first
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
throw new InvalidOperationException("Unable to load two-factor authentication user.");
}
/// <summary>
/// Submits the form.
/// </summary>
private async Task OnValidSubmitAsync()
{
ServerValidationErrors.Clear();
var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty);
var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine);
var userId = await UserManager.GetUserIdAsync(user);
if (result.Succeeded)
{
Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId);
NavigationService.RedirectTo(ReturnUrl);
}
else if (result.IsLockedOut)
{
Logger.LogWarning("User with ID '{UserId}' account locked out.", userId);
NavigationService.RedirectTo("user/lockout");
}
else
{
Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId);
ServerValidationErrors.AddError("Error: Invalid authenticator code.");
}
}
private sealed class InputModel
{
[Required]
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Text)]
[Display(Name = "Authenticator code")]
public string? TwoFactorCode { get; set; }
[Display(Name = "Remember this machine")]
public bool RememberMachine { get; set; }
}
}

View File

@@ -0,0 +1,81 @@
@page "/user/loginWithRecoveryCode"
<LayoutPageTitle>Recovery code verification</LayoutPageTitle>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Recovery code verification
</h2>
<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.
</p>
<div class="w-full max-w-md">
<EditForm Model="Input" FormName="login-with-recovery-code" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
<DataAnnotationsValidator/>
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
<div>
<label for="recovery-code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Recovery Code</label>
<InputText @bind-Value="Input.RecoveryCode" id="recovery-code" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" autocomplete="off" placeholder="Enter your recovery code"/>
<ValidationMessage For="() => Input.RecoveryCode" class="text-red-600 dark:text-red-400 text-sm mt-1"/>
</div>
<button type="submit" class="w-full text-white bg-primary-600 hover:bg-primary-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Log in</button>
</EditForm>
</div>
@code {
private AdminUser user = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
// Ensure the user has gone through the username & password screen first
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
throw new InvalidOperationException("Unable to load two-factor authentication user.");
}
/// <summary>
/// Submits the form.
/// </summary>
private async Task OnValidSubmitAsync()
{
ServerValidationErrors.Clear();
var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
var userId = await UserManager.GetUserIdAsync(user);
if (result.Succeeded)
{
Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId);
NavigationService.RedirectTo(ReturnUrl);
}
else if (result.IsLockedOut)
{
Logger.LogWarning("User account locked out.");
NavigationService.RedirectTo("user/lockout");
}
else
{
Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId);
ServerValidationErrors.AddError("Error: Invalid recovery code entered.");
}
}
private sealed class InputModel
{
[Required]
[DataType(DataType.Text)]
[Display(Name = "Recovery Code")]
public string RecoveryCode { get; set; } = "";
}
}

View File

@@ -0,0 +1,35 @@
@page "/user/logout"
@inject GlobalNotificationService GlobalNotificationService
@code {
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
// Sign out the user.
// NOTE: the try/catch below is a workaround for the issue that the sign out does not work when
// the server session is already started.
try
{
try
{
await SignInManager.SignOutAsync();
GlobalNotificationService.ClearMessages();
// Redirect to the home page with hard refresh.
NavigationService.RedirectTo("/", true);
}
catch
{
// 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.
NavigationService.RedirectTo(NavigationService.Uri, true);
}
}
catch
{
// Redirect to the home page with hard refresh.
NavigationService.RedirectTo("/", true);
}
}
}

View File

@@ -0,0 +1,11 @@
@inherits AuthBase
@using System.ComponentModel.DataAnnotations
@using AliasVault.Admin.Auth.Components
@using AliasVault.Admin.Auth.Layout
@using AliasVault.Admin.Main.Components.Alerts
@using AliasVault.Admin.Main.Components.Layout
@using AliasVault.Admin.Main.Layout
@using AliasVault.Admin.Services
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Identity
@layout AuthLayout

View File

@@ -0,0 +1,67 @@
//-----------------------------------------------------------------------
// <copyright file="RevalidatingAuthenticationStateProvider.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Auth.Providers;
using System.Security.Claims;
using AliasServerDb;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
/// <summary>
/// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
/// every 30 minutes an interactive circuit is connected.
/// </summary>
/// <param name="loggerFactory">ILoggerFactory instance.</param>
/// <param name="scopeFactory">IServiceScopeFactory instance.</param>
/// <param name="options">IOptions instance.</param>
internal sealed class RevalidatingAuthenticationStateProvider(
ILoggerFactory loggerFactory,
IServiceScopeFactory scopeFactory,
IOptions<IdentityOptions> options)
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
{
/// <summary>
/// Gets the revalidation interval.
/// </summary>
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
/// <summary>
/// Validate the authentication state.
/// </summary>
/// <param name="authenticationState">AuthenticationState instance.</param>
/// <param name="cancellationToken">CancellationToken.</param>
/// <returns>Boolean indicating whether the currently logged on user is still valid.</returns>
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
// Get the user manager from a new scope to ensure it fetches fresh data
await using var scope = scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AdminUser>>();
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
}
private async Task<bool> ValidateSecurityStampAsync(UserManager<AdminUser> userManager, ClaimsPrincipal principal)
{
var user = await userManager.GetUserAsync(principal);
if (user is null)
{
return false;
}
if (!userManager.SupportsUserSecurityStamp)
{
return true;
}
var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
var userStamp = await userManager.GetSecurityStampAsync(user);
return principalStamp == userStamp;
}
}

View File

@@ -0,0 +1,12 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using AliasVault.Admin
@using AliasVault.Admin.Main
@using AliasServerDb

View File

@@ -0,0 +1,26 @@
//-----------------------------------------------------------------------
// <copyright file="Config.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin;
/// <summary>
/// Configuration class for the Admin project with values loaded from environment variables.
/// </summary>
public class Config
{
/// <summary>
/// Gets or sets the admin password hash which is generated by install.sh and will be set
/// as the default password for the admin user.
/// </summary>
public string AdminPasswordHash { get; set; } = "false";
/// <summary>
/// Gets or sets the last time the password was changed. This is used to check if the
/// password hash generated by install.sh should replace the current password hash if user already exists.
/// </summary>
public DateTime LastPasswordChanged { get; set; } = DateTime.MinValue;
}

View File

@@ -0,0 +1,32 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8082
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
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
WORKDIR "/src/src/AliasVault.Admin"
RUN dotnet build "AliasVault.Admin.csproj" -c "$BUILD_CONFIGURATION" -o /app/build
# Publish the application to the /app/publish directory in the container
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "AliasVault.Admin.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
EXPOSE 8082
ENV ASPNETCORE_URLS=http://+:8082
ENTRYPOINT ["dotnet", "AliasVault.Admin.dll"]

View File

@@ -0,0 +1,72 @@
<!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="css/tailwind.css"/>
<link rel="stylesheet" href="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="lib/qrcode.min.js?v=dev"></script>
<script src="js/dark-mode.js?v=dev"></script>
<script src="js/utilities.js?v=dev"></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>
</html>
@code {
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
private IComponentRenderMode? RenderModeForPage => HttpContext.Request.Path.StartsWithSegments("/user")
? null
: InteractiveServer;
}

View File

@@ -0,0 +1,18 @@
@inherits ComponentBase
@if (Message == string.Empty)
{
return;
}
<div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 border-2" role="alert">
@Message
</div>
@code {
/// <summary>
/// The message to show.
/// </summary>
[Parameter]
public string Message { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,18 @@
@inherits ComponentBase
@if (Message == string.Empty)
{
return;
}
<div class="p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-gray-800 dark:text-green-400 border-2" role="alert">
@Message
</div>
@code {
/// <summary>
/// The message to show.
/// </summary>
[Parameter]
public string Message { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,71 @@
@implements IDisposable
@foreach (var message in Messages)
{
if (message.Key == "success")
{
<AlertMessageSuccess Message="@message.Value" />
}
}
@foreach (var message in Messages)
{
if (message.Key == "error")
{
<AlertMessageError Message="@message.Value" />
}
}
@code {
private List<KeyValuePair<string, string>> Messages { get; set; } = new();
private bool _onChangeSubscribed = false;
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
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;
}
}
/// <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;
}
}
/// <summary>
/// Refreshes the messages by adding any new messages from the PortalMessageService.
/// </summary>
private void RefreshAddMessages()
{
// We retrieve any additional messages from the GlobalNotificationService that we do not yet have.
var newMessages = GlobalNotificationService.GetMessagesForDisplay();
foreach (var message in newMessages)
{
if (!Messages.Exists(m => m.Key == message.Key && m.Value == message.Value))
{
Messages.Add(message);
}
}
// Remove messages that are no longer in the GlobalNotificationService and have already been displayed.
var messagesToRemove = Messages.Where(m => !newMessages.Exists(nm => nm.Key == m.Key && nm.Value == m.Value)).ToList();
foreach (var message in messagesToRemove)
{
Messages.Remove(message);
}
StateHasChanged();
}
}

View File

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

View File

@@ -0,0 +1,53 @@
@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);
}
}
}

View File

@@ -0,0 +1,9 @@
<PageTitle>@ChildContent - AliasVault Admin</PageTitle>
@code {
/// <summary>
/// Child content.
/// </summary>
[Parameter]
public RenderFragment ChildContent { get; set; } = default!;
}

View File

@@ -0,0 +1,17 @@
@inject NavigationManager NavigationManager
@code {
/// <inheritdoc />
protected override void OnInitialized()
{
var returnUrl = NavigationManager.Uri;
if (string.IsNullOrWhiteSpace(returnUrl) || returnUrl == "/")
{
NavigationManager.NavigateTo($"user/login", forceLoad: true);
}
else
{
NavigationManager.NavigateTo($"user/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
}
}
}

View File

@@ -0,0 +1,52 @@
@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" : "")}";
}
}

View File

@@ -0,0 +1,156 @@
@using AliasVault.WorkerStatus.Database
@inherits MainBase
<button @onclick="SmtpClick"
class="@GetSmtpButtonClasses() mx-3 inline-flex items-center justify-center rounded-xl px-8 py-2 text-white">
<span>SmtpService</span>
@if (smtpPending)
{
<svg class="animate-spin ml-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
}
</button>
@code {
private List<WorkerServiceStatus> serviceStatus = [];
private bool initInProgress;
private bool firstInitPageDone;
private bool smtpStatus;
private bool smtpPending;
private string GetSmtpButtonClasses()
{
string buttonClass = "cursor-pointer ";
if (smtpPending || !firstInitPageDone)
{
buttonClass += "bg-gray-600";
}
else if (smtpStatus)
{
buttonClass += "bg-green-600";
}
else
{
buttonClass += "bg-red-600";
}
return buttonClass;
}
private async void SmtpClick()
{
smtpPending = true;
StateHasChanged();
smtpStatus = !smtpStatus;
await UpdateSmtpStatus(smtpStatus);
smtpPending = false;
StateHasChanged();
}
private async Task InitPage()
{
if (initInProgress)
{
return;
}
if (!smtpPending)
{
initInProgress = true;
serviceStatus = await GetServiceStatuses();
// 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)
{
smtpStatus = (DateTime.Now <= smtpEntry.Heartbeat.AddMinutes(5) && smtpEntry.CurrentStatus == "Started");
}
initInProgress = false;
firstInitPageDone = true;
await InvokeAsync(() => StateHasChanged());
}
else
{
// Service status changing in progress, don't issue parallel requests..
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await InitPage();
}
}
/// <summary>
/// Gets the service statuses.
/// </summary>
public async Task<List<WorkerServiceStatus>> GetServiceStatuses()
{
return await DbContext.WorkerServiceStatuses.ToListAsync();
}
/// <summary>
/// Update the service statuses.
/// </summary>
public async Task<bool> UpdateServiceStatus(string serviceName, bool newStatus)
{
string translateStatus = "";
switch (newStatus)
{
case true:
translateStatus = "Started";
break;
case false:
translateStatus = "Stopped";
break;
}
var entry = await DbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstOrDefaultAsync();
if (entry != null)
{
entry.DesiredStatus = translateStatus;
await DbContext.SaveChangesAsync();
// Wait for service to have stopped with ACK
var timeout = DateTime.Now.AddSeconds(30);
while (true)
{
if (DateTime.Now > timeout)
{
// Timeout
return false;
}
var newdbContext = await DbContextFactory.CreateDbContextAsync();
var check = await newdbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstAsync();
if (check.CurrentStatus == translateStatus)
{
// Done
return true;
}
await Task.Delay(1000);
}
}
return false;
}
/// <summary>
/// Update the SMTP service status.
/// </summary>
public async Task<bool> UpdateSmtpStatus(bool newStatus)
{
return await UpdateServiceStatus("AliasVault.SmtpService", newStatus);
}
}

View File

@@ -0,0 +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.
</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" target="_blank" class="text-sm font-normal text-gray-500 hover:underline dark:text-gray-400">GitHub</a></li>
</ul>
</footer>

View File

@@ -0,0 +1,39 @@
@inherits LayoutComponentBase
@implements IDisposable
@inject NavigationManager NavigationManager
<TopMenu />
<div class="flex pt-16 overflow-hidden bg-gray-50 dark:bg-gray-900">
<div id="main-content" class="relative w-full max-w-screen-2xl mx-auto h-full overflow-y-auto bg-gray-50 dark:bg-gray-900">
<main>
@Body
</main>
<Footer></Footer>
</div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
@code {
/// <inheritdoc />
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
}
/// <inheritdoc />
protected override void OnInitialized()
{
NavigationManager.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
StateHasChanged();
}
}

View File

@@ -0,0 +1,18 @@
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,146 @@
@inherits MainBase
@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">
<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">
<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>
<div class="hidden justify-between items-center w-full lg:flex lg:w-auto lg:order-1">
<ul class="flex flex-col mt-4 space-x-6 text-sm font-medium lg:flex-row xl:space-x-8 lg:mt-0">
<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>
</ul>
</div>
</div>
<div class="flex justify-end items-center lg:order-2">
<Services />
<button id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button" class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5">
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
</button>
<div id="tooltip-toggle" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip" data-popper-placement="bottom" style="position: absolute; inset: 0px auto auto 0px; margin: 0px; transform: translate3d(1377px, 60px, 0px);">
Toggle dark mode
<div class="tooltip-arrow" data-popper-arrow="" style="position: absolute; left: 0px; transform: translate3d(68.5px, 0px, 0px);"></div>
</div>
<button @onclick="ToggleMenu" type="button" class="flex mx-3 text-sm bg-gray-800 rounded-full md:mr-0 flex-shrink-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" id="userMenuDropdownButton" aria-expanded="false" data-dropdown-toggle="userMenuDropdown">
<span class="sr-only">Open user menu</span>
<img class="w-8 h-8 rounded-full" src="/img/avatar.webp" alt="user photo">
</button>
@if (isMenuOpen)
{
<div class="absolute top-10 right-0 z-50 my-4 w-56 text-base list-none bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600" id="userMenuDropdown" data-popper-placement="bottom">
<div class="py-3 px-4">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">@_username</span>
</div>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="userMenuDropdownButton">
<li>
<a href="account/manage" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">Account settings</a>
</li>
</ul>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="dropdown">
<li>
<a href="/user/logout" class="block py-2 px-4 font-bold text-sm text-primary-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-primary-200 dark:hover:text-white">Sign out</a>
</li>
</ul>
</div>
}
<button @onclick="ToggleMobileMenu" type="button" id="toggleMobileMenuButton" class="items-center p-2 text-gray-500 rounded-lg md:ml-2 lg:hidden hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600">
<span class="sr-only">Open menu</span>
<svg class="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>
</button>
</div>
</div>
</nav>
@if (isMobileMenuOpen)
{
<nav class="bg-white dark:bg-gray-900">
<ul id="mobileMenu" class="flex-col mt-0 pt-16 w-full text-sm font-medium lg:hidden">
<li class="block border-b dark:border-gray-700">
<NavLink href="/" 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">
Home
</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>
</li>
</ul>
</nav>
}
</header>
@code {
private bool isMenuOpen = false;
private bool isMobileMenuOpen = false;
private string _username { get; set; } = "";
/// <summary>
/// Close the menu.
/// </summary>
[JSInvokable]
public void CloseMenu()
{
isMenuOpen = false;
isMobileMenuOpen = false;
StateHasChanged();
}
/// <summary>
/// Dispose method.
/// </summary>
public void Dispose()
{
NavigationService.LocationChanged -= LocationChanged;
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
_username = GetUsername();
NavigationService.LocationChanged += LocationChanged;
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await Js.InvokeVoidAsync("window.initTopMenu");
DotNetObjectReference<TopMenu> objRef = DotNetObjectReference.Create(this);
await Js.InvokeVoidAsync("window.registerClickOutsideHandler", objRef);
}
}
private void LocationChanged(object? sender, LocationChangedEventArgs e)
{
isMenuOpen = false;
isMobileMenuOpen = false;
StateHasChanged();
}
private void ToggleMenu()
{
isMenuOpen = !isMenuOpen;
}
private void ToggleMobileMenu()
{
isMobileMenuOpen = !isMobileMenuOpen;
}
}

View File

@@ -0,0 +1,24 @@
//-----------------------------------------------------------------------
// <copyright file="BreadcrumbItem.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Main.Models;
/// <summary>
/// Breadcrumb item model.
/// </summary>
public class BreadcrumbItem
{
/// <summary>
/// Gets or sets the display name.
/// </summary>
public string? DisplayName { get; set; }
/// <summary>
/// Gets or sets the URL.
/// </summary>
public string? Url { get; set; }
}

View File

@@ -0,0 +1,88 @@
@page "/account/manage/change-password"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@inject UserManager<AdminUser> UserManager
@inject ILogger<ChangePassword> Logger
<LayoutPageTitle>Change password</LayoutPageTitle>
<div class="max-w-2xl mx-auto">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Change password</h3>
<EditForm Model="Input" FormName="change-password" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
<DataAnnotationsValidator/>
<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."/>
<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."/>
<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."/>
<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>
</div>
</EditForm>
</div>
@code {
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
private async Task OnValidSubmitAsync()
{
var changePasswordResult = await UserManager.ChangePasswordAsync(UserService.User(), Input.OldPassword, Input.NewPassword);
var user = UserService.User();
user.LastPasswordChanged = DateTime.UtcNow;
await UserService.UpdateUserAsync(user);
// Clear the password fields
Input.OldPassword = "";
Input.NewPassword = "";
Input.ConfirmPassword = "";
if (!changePasswordResult.Succeeded)
{
GlobalNotificationService.AddErrorMessage($"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}", true);
return;
}
Logger.LogInformation("User changed their password successfully.");
GlobalNotificationService.AddSuccessMessage("Your password has been changed.", true);
NavigationService.RedirectToCurrentPage();
}
private sealed class InputModel
{
[Required]
[DataType(DataType.Password)]
[Display(Name = "Current password")]
public string OldPassword { get; set; } = "";
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "New password")]
public string NewPassword { get; set; } = "";
[DataType(DataType.Password)]
[Display(Name = "Confirm new password")]
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
public string ConfirmPassword { get; set; } = "";
}
}

View File

@@ -0,0 +1,25 @@
<h3 class="text-lg font-medium">Recovery codes</h3>
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4" role="alert">
<p class="font-semibold">
Put these codes in a safe place.
</p>
<p>
If you lose your device and don't have the recovery codes you will lose access to your account.
</p>
</div>
<div class="grid grid-cols-1">
@foreach (var recoveryCode in RecoveryCodes)
{
<div>
<code class="block p-2 bg-primary-200 rounded">@recoveryCode</code>
</div>
}
</div>
@code {
/// <summary>
/// The recovery codes to show.
/// </summary>
[Parameter]
public string[] RecoveryCodes { get; set; } = [];
}

View File

@@ -0,0 +1,54 @@
@page "/account/manage/disable-2fa"
@using Microsoft.AspNetCore.Identity
@inject UserManager<AdminUser> UserManager
@inject ILogger<Disable2fa> Logger
<LayoutPageTitle>Disable two-factor authentication (2FA)</LayoutPageTitle>
<h3 class="text-xl font-bold mb-4">Disable two-factor authentication (2FA)</h3>
<div class="bg-primary-100 border-l-4 border-primary-500 text-primary-700 p-4 mb-4" role="alert">
<p class="font-bold mb-2">
This action only disables 2FA.
</p>
<p>
Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
used in an authenticator app you should <a href="account/manage/reset-authenticator" class="text-primary-600 hover:text-primary-800 underline">reset your authenticator keys.</a>
</p>
</div>
<div>
<form @formname="disable-2fa" @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">Disable 2FA</button>
</form>
</div>
@code {
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
if (!await UserManager.GetTwoFactorEnabledAsync(UserService.User()))
{
throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled.");
}
}
private async Task OnSubmitAsync()
{
var disable2FaResult = await UserManager.SetTwoFactorEnabledAsync(UserService.User(), false);
if (!disable2FaResult.Succeeded)
{
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);
// Reload current page.
NavigationService.RedirectTo(NavigationService.Uri, forceLoad: true);
}
}

View File

@@ -0,0 +1,170 @@
@page "/account/manage/enable-authenticator"
@using System.ComponentModel.DataAnnotations
@using System.Globalization
@using System.Text
@using System.Text.Encodings.Web
@using Microsoft.AspNetCore.Identity
@inject UserManager<AdminUser> UserManager
@inject UrlEncoder UrlEncoder
@inject ILogger<EnableAuthenticator> Logger
<LayoutPageTitle>Configure authenticator app</LayoutPageTitle>
@if (recoveryCodes is not null)
{
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()"/>
}
else
{
<div class="max-w-2xl mx-auto">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Configure authenticator app</h3>
<div class="space-y-6">
<p class="text-gray-700 dark:text-gray-300">To use an authenticator app go through the following steps:</p>
<ol class="list-decimal space-y-4">
<li>
<p class="text-gray-700 dark:text-gray-300">
Download a two-factor authenticator app like Microsoft Authenticator for
<a href="https://go.microsoft.com/fwlink/?Linkid=825072" class="text-blue-600 hover:underline dark:text-blue-400">Android</a> and
<a href="https://go.microsoft.com/fwlink/?Linkid=825073" class="text-blue-600 hover:underline dark:text-blue-400">iOS</a> or
Google Authenticator for
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&amp;hl=en" class="text-blue-600 hover:underline dark:text-blue-400">Android</a> and
<a href="https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8" class="text-blue-600 hover:underline dark:text-blue-400">iOS</a>.
</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>
</li>
<li>
<p class="text-gray-700 dark:text-gray-300">
Once you have scanned the QR code or input the key above, your two factor authentication app will provide you
with a unique code. Enter the code in the confirmation box below.
</p>
<div class="mt-4">
<EditForm Model="Input" FormName="send-code" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-4">
<DataAnnotationsValidator/>
<div>
<label for="code" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Verification Code</label>
<InputText @bind-Value="Input.Code" id="code" 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="off" placeholder="Please enter the code."/>
<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>
</div>
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
</EditForm>
</div>
</li>
</ol>
</div>
</div>
}
@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;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadSharedKeyAndQrCodeUriAsync(UserService.User());
await JsInvokeService.RetryInvokeAsync("generateQrCode", TimeSpan.Zero, 5, "authenticator-uri");
}
private async Task OnValidSubmitAsync()
{
// Strip spaces and hyphens
var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
var is2faTokenValid = await UserManager.VerifyTwoFactorTokenAsync(
UserService.User(), UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
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);
GlobalNotificationService.AddSuccessMessage("Your authenticator app has been verified.");
if (await UserManager.CountRecoveryCodesAsync(UserService.User()) == 0)
{
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10);
}
else
{
// Navigate back to the two factor authentication page.
NavigationService.RedirectTo("account/manage/2fa", forceLoad: true);
}
}
private async ValueTask LoadSharedKeyAndQrCodeUriAsync(AdminUser user)
{
// Load the authenticator key & QR code URI to display on the form
var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(unformattedKey))
{
await UserManager.ResetAuthenticatorKeyAsync(user);
unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
}
sharedKey = FormatKey(unformattedKey!);
var username = await UserManager.GetUserNameAsync(user);
authenticatorUri = GenerateQrCodeUri(username!, unformattedKey!);
}
private string FormatKey(string unformattedKey)
{
var result = new StringBuilder();
int currentPosition = 0;
while (currentPosition + 4 < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
currentPosition += 4;
}
if (currentPosition < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition));
}
return result.ToString().ToLowerInvariant();
}
private string GenerateQrCodeUri(string username, string unformattedKey)
{
return string.Format(
CultureInfo.InvariantCulture,
AuthenticatorUriFormat,
UrlEncoder.Encode("AliasVault Admin"),
UrlEncoder.Encode(username),
unformattedKey);
}
private sealed class InputModel
{
[Required]
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Text)]
[Display(Name = "Verification Code")]
public string Code { get; set; } = "";
}
}

View File

@@ -0,0 +1,61 @@
@page "/account/manage/generate-recovery-codes"
@using Microsoft.AspNetCore.Identity
@inject UserManager<AdminUser> UserManager
@inject ILogger<GenerateRecoveryCodes> Logger
<LayoutPageTitle>Generate two-factor authentication (2FA) recovery codes</LayoutPageTitle>
@if (recoveryCodes is not null)
{
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()"/>
}
else
{
<h3 class="text-xl font-bold mb-4">Generate two-factor authentication (2FA) recovery codes</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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<strong>Put these codes in a safe place.</strong>
</p>
<p class="mb-2">
If you lose your device and don't have the recovery codes you will lose access to your account.
</p>
<p>
Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
used in an authenticator app you should <a href="account/manage/reset-authenticator" class="text-primary-600 hover:text-primary-800 underline">reset your authenticator keys.</a>
</p>
</div>
<div>
<button class="bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded" @onclick="GenerateCodes" type="submit">Generate Recovery Codes</button>
</div>
}
@code {
private IEnumerable<string>? recoveryCodes;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(UserService.User());
if (!isTwoFactorEnabled)
{
throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled.");
}
}
private async Task GenerateCodes()
{
var userId = await UserManager.GetUserIdAsync(UserService.User());
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(UserService.User(), 10);
GlobalNotificationService.AddSuccessMessage("You have generated new recovery codes.");
Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
}
}

View File

@@ -0,0 +1,69 @@
@page "/account/manage"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@inject UserManager<AdminUser> UserManager
<LayoutPageTitle>Profile</LayoutPageTitle>
<div class="max-w-2xl mx-auto">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Profile</h3>
<EditForm Model="Input" FormName="profile" OnValidSubmit="OnValidSubmitAsync" class="space-y-6">
<DataAnnotationsValidator/>
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
<div>
<label for="username" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Username</label>
<input type="text" value="@username" id="username" 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 bg-gray-100 cursor-not-allowed dark:bg-gray-700 dark:border-gray-600 dark:text-gray-400" placeholder="Please choose your username." disabled/>
</div>
<div>
<label for="phone-number" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Phone number</label>
<InputText @bind-Value="Input.PhoneNumber" id="phone-number" 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" placeholder="Please enter your phone number."/>
<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>
</div>
</EditForm>
</div>
@code {
private string? username;
private string? phoneNumber;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
username = await UserManager.GetUserNameAsync(UserService.User());
phoneNumber = await UserManager.GetPhoneNumberAsync(UserService.User());
Input.PhoneNumber ??= phoneNumber;
}
private async Task OnValidSubmitAsync()
{
if (Input.PhoneNumber != phoneNumber)
{
var setPhoneResult = await UserManager.SetPhoneNumberAsync(UserService.User(), Input.PhoneNumber);
if (!setPhoneResult.Succeeded)
{
GlobalNotificationService.AddErrorMessage("Phone number could not be set", true);
}
}
GlobalNotificationService.AddSuccessMessage("Your profile has been updated", true);
}
private sealed class InputModel
{
[Phone]
[Display(Name = "Phone number")]
public string? PhoneNumber { get; set; }
}
}

View File

@@ -0,0 +1,44 @@
@page "/account/manage/reset-authenticator"
@using Microsoft.AspNetCore.Identity
@inject UserManager<AdminUser> UserManager
@inject ILogger<ResetAuthenticator> Logger
<LayoutPageTitle>Reset authenticator key</LayoutPageTitle>
<h3 class="text-xl font-bold mb-4">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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<strong>If you reset your authenticator key your authenticator app will not work until you reconfigure it.</strong>
</p>
<p>
This process disables 2FA until you verify your authenticator app.
If you do not complete your authenticator app configuration you may lose access to your account.
</p>
</div>
<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>
</form>
</div>
@code {
private async Task OnSubmitAsync()
{
await UserManager.SetTwoFactorEnabledAsync(UserService.User(), false);
await UserManager.ResetAuthenticatorKeyAsync(UserService.User());
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);
NavigationService.RedirectTo(
"account/manage/2fa");
}
}

View File

@@ -0,0 +1,79 @@
@page "/account/manage/2fa"
@using Microsoft.AspNetCore.Identity
@inject UserManager<AdminUser> UserManager
@inject SignInManager<AdminUser> SignInManager
<LayoutPageTitle>Two-factor authentication (2FA)</LayoutPageTitle>
@if (is2FaEnabled)
{
<div class="mx-auto mt-8 p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Two-factor authentication (2FA)</h3>
@if (recoveryCodesLeft == 0)
{
<div class="mb-4 p-4 bg-red-100 border-l-4 border-red-500 text-red-700 dark:bg-red-900 dark:text-red-100">
<p class="font-bold">You have no recovery codes left.</p>
<p>You must <a href="account/manage/generate-recovery-codes" class="text-red-800 dark:text-red-200 underline">generate a new set of recovery codes</a> before you can log in with a recovery code.</p>
</div>
}
else if (recoveryCodesLeft == 1)
{
<div class="mb-4 p-4 bg-red-100 border-l-4 border-red-500 text-red-700 dark:bg-red-900 dark:text-red-100">
<p class="font-bold">You have 1 recovery code left.</p>
<p>You can <a href="account/manage/generate-recovery-codes" class="text-red-800 dark:text-red-200 underline">generate a new set of recovery codes</a>.</p>
</div>
}
else if (recoveryCodesLeft <= 3)
{
<div class="mb-4 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-100">
<p class="font-bold">You have @recoveryCodesLeft recovery codes left.</p>
<p>You should <a href="account/manage/generate-recovery-codes" class="text-yellow-800 dark:text-yellow-200 underline">generate a new set of recovery codes</a>.</p>
</div>
}
<div class="flex space-x-4">
<a href="account/manage/disable-2fa" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm focus:ring-4 focus:outline-none focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Disable 2FA</a>
<a href="account/manage/generate-recovery-codes" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg text-sm 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 recovery codes</a>
</div>
</div>
}
<div class="mt-6 p-4 bg-gray-100 dark:bg-gray-700 rounded-lg">
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Authenticator app</h4>
<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>
}
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>
}
</div>
</div>
@code {
private bool hasAuthenticator;
private int recoveryCodesLeft;
private bool is2FaEnabled;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(UserService.User()) is not null;
is2FaEnabled = await UserManager.GetTwoFactorEnabledAsync(UserService.User());
recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(UserService.User());
}
}

View File

@@ -0,0 +1,6 @@
@layout ManageLayout
@inherits MainBase
@using AliasVault.Admin.Auth
@using AliasVault.Admin.Main.Pages.Account.Manage.Components
@using AliasVault.Admin.Main.Components.Layout
@attribute [Microsoft.AspNetCore.Authorization.Authorize]

View File

@@ -0,0 +1,26 @@
@inherits LayoutComponentBase
@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>
<div class="container mx-auto px-4 py-8">
<hr class="mb-6 border-t border-gray-300"/>
<div class="flex flex-col md:flex-row">
<div class="w-full md:w-1/4 mb-6 md:mb-0">
<ManageNavMenu/>
</div>
<div class="w-full md:w-3/4 md:pl-8">
@Body
</div>
</div>
</div>

View File

@@ -0,0 +1,15 @@
@using Microsoft.AspNetCore.Identity
@inject SignInManager<AdminUser> SignInManager
<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>
</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>
</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>
</li>
</ul>

View File

@@ -0,0 +1,104 @@
@page "/emails"
@using AliasVault.RazorComponents
@using Azure
<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>
@if (IsLoading)
{
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
}
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">
<span class="font-medium">@email.FromLocal</span>@@@email.FromDomain
</td>
<td class="px-4 py-3">
<span class="font-medium">@email.ToLocal</span>@@@email.ToDomain
</td>
<td class="px-4 py-3">@email.Subject</td>
<td class="px-4 py-3">
<span class="line-clamp-1">@email.MessagePreview</span>
</td>
<td class="px-4 py-3">
@email.Attachments.Count
</td>
</tr>
}
</tbody>
</table>
</div>
}
@code {
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; }
/// <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();
TotalRecords = await DbContext.Emails.CountAsync();
EmailList = await DbContext.Emails
.OrderByDescending(x => x.DateSystem)
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
IsLoading = false;
StateHasChanged();
}
}

View File

@@ -0,0 +1,37 @@
@page "/Error"
@using System.Diagnostics
<LayoutPageTitle>Error</LayoutPageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter] private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
/// <inheritdoc />
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -0,0 +1,15 @@
@page "/"
@inherits MainBase
<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>

View File

@@ -0,0 +1,152 @@
@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)
{
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
}
else
{
<div class="px-4">
<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">
</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 id="logTableBody">
@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;
switch (log.Level)
{
case "Information":
bgColor = "bg-blue-500";
break;
case "Error":
bgColor = "bg-red-500";
break;
case "Warning":
bgColor = "bg-yellow-500";
break;
case "Debug":
bgColor = "bg-green-500";
break;
default:
bgColor = "bg-gray-500";
break;
}
}
<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();
}
}
}
/// <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();
if (SearchTerm.Length > 0)
{
var filteredQuery = DbContext.Logs
.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() + "%"));
TotalRecords = await filteredQuery.CountAsync();
LogList = await filteredQuery
.OrderByDescending(x => x.Id)
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
}
else
{
TotalRecords = await DbContext.Logs.CountAsync();
LogList = await DbContext.Logs
.OrderByDescending(x => x.Id)
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
}
IsLoading = false;
StateHasChanged();
}
}

View File

@@ -0,0 +1,99 @@
//-----------------------------------------------------------------------
// <copyright file="MainBase.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Main.Pages;
using AliasServerDb;
using AliasVault.Admin.Main.Models;
using AliasVault.Admin.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.EntityFrameworkCore;
using Microsoft.JSInterop;
/// <summary>
/// Base authorize page that all pages that are part of the logged in website should inherit from.
/// All pages that inherit from this class will require the user to be logged in and have a confirmed email.
/// Also, a default set of breadcrumbs is added in the parent OnInitialized method.
/// </summary>
[Authorize]
public class MainBase : OwningComponentBase
{
/// <summary>
/// Gets or sets the NavigationService instance responsible for handling navigation, replaces the default NavigationManager.
/// </summary>
[Inject]
public NavigationService NavigationService { get; set; } = null!;
/// <summary>
/// Gets or sets the UserService instance responsible for handling user data.
/// </summary>
[Inject]
protected UserService UserService { get; set; } = null!;
/// <summary>
/// Gets or sets the global notification service for showing notifications throughout the app.
/// </summary>
[Inject]
protected GlobalNotificationService GlobalNotificationService { get; set; } = null!;
/// <summary>
/// Gets or sets the JS invoke service for calling JS functions from C#.
/// </summary>
[Inject]
protected JsInvokeService JsInvokeService { get; set; } = null!;
/// <summary>
/// Gets or sets the AliasServerDbContext instance.
/// </summary>
[Inject]
protected AliasServerDbContext DbContext { get; set; } = null!;
/// <summary>
/// Gets or sets the AliasServerDbContextFactory instance.
/// </summary>
[Inject]
protected IDbContextFactory<AliasServerDbContext> DbContextFactory { get; set; } = null!;
/// <summary>
/// Gets or sets the injected JSRuntime instance.
/// </summary>
[Inject]
protected IJSRuntime Js { get; set; } = null!;
/// <summary>
/// Gets the breadcrumb items for the page. A default set of breadcrumbs is added in the parent OnInitialized method.
/// </summary>
protected List<BreadcrumbItem> BreadcrumbItems { get; } = new();
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Load the current user.
await UserService.LoadCurrentUserAsync();
// Add base breadcrumbs.
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Home", Url = NavigationService.BaseUri });
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
}
/// <summary>
/// Gets the username from the authentication state asynchronously.
/// </summary>
/// <returns>The username.</returns>
protected string GetUsername()
{
return UserService.User().UserName ?? "[Unknown]";
}
}

View File

@@ -0,0 +1,10 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin/>
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>

View File

@@ -0,0 +1,25 @@
@inherits AliasVault.Admin.Main.Pages.MainBase
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.EntityFrameworkCore
@using Microsoft.JSInterop
@using AliasVault.Admin
@using AliasVault.Admin.Auth.Components
@using AliasVault.Admin.Main
@using AliasVault.Admin.Main.Components
@using AliasVault.Admin.Main.Components.Alerts
@using AliasVault.Admin.Main.Components.Layout
@using AliasVault.Admin.Main.Components.WorkerStatus
@using AliasVault.Admin.Main.Components.Refresh
@using AliasVault.Admin.Main.Models
@using AliasVault.Admin.Main.Pages
@using AliasVault.Admin.Services
@using AliasServerDb
@using Microsoft.AspNetCore.Authorization

View File

@@ -0,0 +1,135 @@
//-----------------------------------------------------------------------
// <copyright file="Program.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>
//-----------------------------------------------------------------------
using System.Data.Common;
using System.Globalization;
using System.Reflection;
using AliasServerDb;
using AliasVault.Admin;
using AliasVault.Admin.Auth.Providers;
using AliasVault.Admin.Main;
using AliasVault.Admin.Services;
using AliasVault.Logging;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true);
builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAssembly().GetName().Name!, "../../logs");
// Create global config object, get values from environment variables.
Config config = new Config();
var adminPasswordHash = Environment.GetEnvironmentVariable("ADMIN_PASSWORD_HASH") ?? throw new KeyNotFoundException("ADMIN_PASSWORD_HASH environment variable is not set.");
config.AdminPasswordHash = adminPasswordHash;
var lastPasswordChanged = Environment.GetEnvironmentVariable("ADMIN_PASSWORD_GENERATED") ?? throw new KeyNotFoundException("ADMIN_PASSWORD_GENERATED environment variable is not set.");
config.LastPasswordChanged = DateTime.ParseExact(lastPasswordChanged, "yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
builder.Services.AddSingleton(config);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<JsInvokeService>();
builder.Services.AddScoped<GlobalNotificationService>();
builder.Services.AddScoped<NavigationService>();
builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingAuthenticationStateProvider>();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
.AddIdentityCookies();
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.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddIdentityCore<AdminUser>(options =>
{
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequiredLength = 8;
options.Password.RequiredUniqueChars = 0;
options.SignIn.RequireConfirmedAccount = false;
options.User.RequireUniqueEmail = false;
})
.AddRoles<AdminRole>()
.AddEntityFrameworkStores<AliasServerDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseMigrationsEndPoint();
}
else
{
app.UseHttpsRedirection();
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
using (var scope = app.Services.CreateScope())
{
var container = scope.ServiceProvider;
var db = container.GetRequiredService<AliasServerDbContext>();
await db.Database.MigrateAsync();
await StartupTasks.CreateRolesIfNotExist(scope.ServiceProvider);
await StartupTasks.SetAdminUser(scope.ServiceProvider);
}
await app.RunAsync();
namespace AliasVault.Admin
{
/// <summary>
/// Explicit program class definition. This is required in order to start the Admin project
/// in-memory from E2ETests project via WebApplicationFactory.
/// </summary>
public partial class Program
{
}
}

View File

@@ -0,0 +1,40 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:12292",
"sslPort": 44398
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5216",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ADMIN_PASSWORD_HASH": "AQAAAAIAAYagAAAAEKWfKfa2gh9Z72vjAlnNP1xlME7FsunRznzyrfqFte40FToufRwa3kX8wwDwnEXZag==",
"ADMIN_PASSWORD_GENERATED": "2024-01-01T00:00:00Z"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7025;http://localhost:5216",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,103 @@
//-----------------------------------------------------------------------
// <copyright file="GlobalNotificationService.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Services;
/// <summary>
/// Handles global notifications that should be displayed to the user, such as success or error messages. These messages
/// are stored in this object which is scoped to the current session. This allows the messages to be cached until
/// they actually have been displayed. So they can survive redirects and page reloads.
/// </summary>
public class GlobalNotificationService
{
/// <summary>
/// Allow other components to subscribe to changes in the event object.
/// </summary>
public event Action? OnChange;
/// <summary>
/// Gets or sets success messages that should be displayed to the user.
/// </summary>
protected List<string> SuccessMessages { get; set; } = [];
/// <summary>
/// Gets or sets error messages that should be displayed to the user.
/// </summary>
protected List<string> ErrorMessages { get; set; } = [];
/// <summary>
/// Adds a success 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 AddSuccessMessage(string message, bool notifyStateChanged = false)
{
SuccessMessages.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>
/// <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 AddErrorMessage(string message, bool notifyStateChanged = false)
{
ErrorMessages.Add(message);
// Notify subscribers that a message has been added.
if (notifyStateChanged)
{
NotifyStateChanged();
}
}
/// <summary>
/// Returns a dictionary with messages that should be displayed to the user. After this method is called,
/// the messages are automatically cleared.
/// </summary>
/// <returns>Dictionary with messages that are ready to be displayed on the next page load.</returns>
public List<KeyValuePair<string, string>> GetMessagesForDisplay()
{
var messages = new List<KeyValuePair<string, string>>();
foreach (var message in SuccessMessages)
{
messages.Add(new KeyValuePair<string, string>("success", message));
}
foreach (var message in ErrorMessages)
{
messages.Add(new KeyValuePair<string, string>("error", message));
}
// Clear messages
SuccessMessages.Clear();
ErrorMessages.Clear();
return messages;
}
/// <summary>
/// Clear all messages.
/// </summary>
public void ClearMessages()
{
SuccessMessages.Clear();
ErrorMessages.Clear();
}
private void NotifyStateChanged() => OnChange?.Invoke();
}

View File

@@ -0,0 +1,53 @@
//-----------------------------------------------------------------------
// <copyright file="JSInvokeService.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Services;
using Microsoft.JSInterop;
/// <summary>
/// Service for invoking JavaScript functions from C#.
/// </summary>
public class JsInvokeService(IJSRuntime js)
{
/// <summary>
/// Invoke a JavaScript function with retry and exponential backoff.
/// </summary>
/// <param name="functionName">The JS function name to call.</param>
/// <param name="initialDelay">Initial delay before calling the function.</param>
/// <param name="maxAttempts">Maximum attempts before giving up.</param>
/// <param name="args">Arguments to pass on to the javascript function.</param>
/// <returns>Async Task.</returns>
public async Task RetryInvokeAsync(string functionName, TimeSpan initialDelay, int maxAttempts, params object[] args)
{
TimeSpan delay = initialDelay;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
bool isDefined = await js.InvokeAsync<bool>("isFunctionDefined", functionName);
if (isDefined)
{
await js.InvokeVoidAsync(functionName, args);
return; // Successfully called the JS function, exit the method
}
}
catch
{
// Optionally log the exception
}
// Wait for the delay before the next attempt
await Task.Delay(delay);
// Exponential backoff: double the delay for the next attempt
delay = TimeSpan.FromTicks(delay.Ticks * 2);
}
// Optionally log that the JS function could not be called after maxAttempts
}
}

View File

@@ -0,0 +1,102 @@
//-----------------------------------------------------------------------
// <copyright file="NavigationService.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
/// <summary>
/// Navigation helper service.
/// </summary>
public class NavigationService
{
private readonly NavigationManager _navigationManager;
/// <summary>
/// Initializes a new instance of the <see cref="NavigationService"/> class.
/// </summary>
/// <param name="navigationManager">NavigationManager instance.</param>
public NavigationService(NavigationManager navigationManager)
{
_navigationManager = navigationManager;
_navigationManager.LocationChanged += (sender, args) => { LocationChanged?.Invoke(sender, args); };
}
/// <summary>
/// Location changed event.
/// </summary>
public event EventHandler<LocationChangedEventArgs>? LocationChanged;
/// <summary>
/// Gets the Base URI.
/// </summary>
public string BaseUri => _navigationManager.BaseUri;
/// <summary>
/// Gets the URI.
/// </summary>
public string Uri => _navigationManager.Uri;
/// <summary>
/// Gets the current path.
/// </summary>
private string CurrentPath => _navigationManager.ToAbsoluteUri(_navigationManager.Uri).GetLeftPart(UriPartial.Path);
/// <summary>
/// Redirect to the current page.
/// </summary>
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
/// <summary>
/// Redirect to the specified URI.
/// </summary>
/// <param name="uri">The uri to redirect to.</param>
/// <param name="forceLoad">Force load true/false.</param>
public void RedirectTo(string? uri, bool forceLoad = false)
{
uri ??= string.Empty;
// Prevent open redirects.
if (!System.Uri.IsWellFormedUriString(uri, UriKind.Relative))
{
uri = _navigationManager.ToBaseRelativePath(uri);
}
_navigationManager.NavigateTo(uri, forceLoad);
}
/// <summary>
/// Redirect to the specified URI with query parameters.
/// </summary>
/// <param name="uri">URI to redirect to.</param>
/// <param name="queryParameters">Optional querystring parameters to add to the URL.</param>
/// <param name="forceLoad">Force load true/false.</param>
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters, bool forceLoad = false)
{
var uriWithoutQuery = _navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
var newUri = _navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
RedirectTo(newUri, forceLoad);
}
/// <summary>
/// Returns a URI constructed from <paramref name="uri" /> except with multiple parameters
/// added, updated, or removed.
/// </summary>
/// <param name="uri">The URI with the query to modify.</param>
/// <param name="parameters">The values to add, update, or remove.</param>
/// <returns>The URI with the query modified.</returns>
public string GetUriWithQueryParameters(string uri, IReadOnlyDictionary<string, object?> parameters) => _navigationManager.GetUriWithQueryParameters(uri, parameters);
/// <summary>
/// Converts a relative URI into an absolute one (by resolving it
/// relative to the current absolute URI).
/// </summary>
/// <param name="relativeUri">The relative URI.</param>
/// <returns>The absolute URI.</returns>
public Uri ToAbsoluteUri(string relativeUri) => _navigationManager.ToAbsoluteUri(relativeUri);
}

View File

@@ -0,0 +1,331 @@
//-----------------------------------------------------------------------
// <copyright file="UserService.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Services;
using System.ComponentModel.DataAnnotations;
using AliasServerDb;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// User service for managing users.
/// </summary>
/// <param name="dbContext">AliasServerDbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
/// <param name="httpContextAccessor">HttpContextManager instance.</param>
public class UserService(AliasServerDbContext dbContext, UserManager<AdminUser> userManager, IHttpContextAccessor httpContextAccessor)
{
private const string AdminRole = "Admin";
private AdminUser? _user;
/// <summary>
/// The roles of the current user.
/// </summary>
private List<string> _userRoles = [];
/// <summary>
/// Whether the current user is an admin or not.
/// </summary>
private bool _isAdmin;
/// <summary>
/// Allow other components to subscribe to changes in the event object.
/// </summary>
public event Action OnChange = () => { };
/// <summary>
/// Gets a value indicating whether the User is loaded and available, false if not. Use this before accessing User() method.
/// </summary>
public bool UserLoaded => _user != null;
/// <summary>
/// Returns all users.
/// </summary>
/// <returns>List of users.</returns>
public async Task<List<AdminUser>> GetAllUsersAsync()
{
var userList = await userManager.Users.ToListAsync();
return userList;
}
/// <summary>
/// Finds and returns user by id, using the userManager instead of the dbContext.
/// This is necessary when performing actions on the user, such as changing password or deleting the object.
/// </summary>
/// <param name="userId">User ID.</param>
/// <returns>AdminUser object.</returns>
public async Task<AdminUser> GetUserByIdUserManagerAsync(Guid userId)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user == null)
{
throw new ArgumentException($"User with id {userId} not found.");
}
return user;
}
/// <summary>
/// Returns inner User EF object.
/// </summary>
/// <returns>User object.</returns>
public AdminUser User()
{
if (_user == null)
{
throw new ArgumentException("Trying to access User object which is null.");
}
return _user;
}
/// <summary>
/// Returns whether current user is admin or not.
/// </summary>
/// <returns>Boolean which indicates if user is admin.</returns>
public bool CurrentUserIsAdmin()
{
return _isAdmin;
}
/// <summary>
/// Returns current logged on user based on HttpContext.
/// </summary>
/// <returns>Async task.</returns>
public async Task LoadCurrentUserAsync()
{
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 user = await dbContext.AdminUsers.FirstOrDefaultAsync(u => u.UserName == userName);
if (user != null)
{
_user = user;
// Load all roles for current user.
var roles = await userManager.GetRolesAsync(User());
_userRoles = roles.ToList();
// Define if current user is admin.
_isAdmin = _userRoles.Contains(AdminRole);
}
}
// Notify listeners that the user has been loaded.
NotifyStateChanged();
}
/// <summary>
/// Returns current logged on user roles based on HttpContext.
/// </summary>
/// <returns>List of roles.</returns>
public async Task<List<string>> GetCurrentUserRolesAsync()
{
var roles = await userManager.GetRolesAsync(User());
return roles.ToList();
}
/// <summary>
/// Search for users based on search term.
/// </summary>
/// <param name="searchTerm">Search term.</param>
/// <returns>List of users matching the search term.</returns>
public async Task<List<AdminUser>> SearchUsersAsync(string searchTerm)
{
return await userManager.Users.Where(x => x.UserName != null && x.UserName.Contains(searchTerm)).Take(5).ToListAsync();
}
/// <summary>
/// Create a new user.
/// </summary>
/// <param name="user">User object.</param>
/// <param name="password">Password.</param>
/// <param name="roles">Roles.</param>
/// <returns>List of errors if there are any.</returns>
public async Task<List<string>> CreateUserAsync(AdminUser user, string password, List<string> roles)
{
var errors = await ValidateUser(user, password, isUpdate: false);
if (errors.Count > 0)
{
return errors;
}
var result = await userManager.CreateAsync(user, password);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
errors.Add(error.Description);
}
return errors;
}
errors = await UpdateUserRolesAsync(user, roles);
return errors;
}
/// <summary>
/// Update user.
/// </summary>
/// <param name="user">User object.</param>
/// <param name="newPassword">Optional parameter for new password for the user.</param>
/// <returns>List of errors if any.</returns>
public async Task<List<string>> UpdateUserAsync(AdminUser user, string newPassword = "")
{
var errors = await ValidateUser(user, newPassword, isUpdate: true);
if (errors.Count > 0)
{
return errors;
}
// Update password if necessary
if (!string.IsNullOrEmpty(newPassword))
{
var passwordRemoveResult = await userManager.RemovePasswordAsync(user);
if (!passwordRemoveResult.Succeeded)
{
foreach (var error in passwordRemoveResult.Errors)
{
errors.Add(error.Description);
}
return errors;
}
var passwordAddResult = await userManager.AddPasswordAsync(user, newPassword);
if (!passwordAddResult.Succeeded)
{
foreach (var error in passwordAddResult.Errors)
{
errors.Add(error.Description);
}
return errors;
}
}
var result = await userManager.UpdateAsync(user);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
errors.Add(error.Description);
}
return errors;
}
return errors;
}
/// <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)
{
List<string> errors = new();
var currentRoles = await userManager.GetRolesAsync(user);
if (user.Id == User().Id && currentRoles.Contains(AdminRole) && !roles.Contains(AdminRole))
{
errors.Add("You cannot remove the Admin role from yourself if you are an Admin.");
return errors;
}
var rolesToAdd = roles.Except(currentRoles).ToList();
var rolesToRemove = currentRoles.Except(roles).ToList();
await userManager.AddToRolesAsync(user, rolesToAdd);
await userManager.RemoveFromRolesAsync(user, rolesToRemove);
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>
/// <param name="user">User object.</param>
/// <param name="password">Password for the user.</param>
/// <param name="isUpdate">Boolean indicating whether the user is being updated or not.</param>
/// <returns>List of strings.</returns>
private async Task<List<string>> ValidateUser(AdminUser user, string password, bool isUpdate)
{
// Username and email are the same, so enforce any changes to username here to email as well
user.Email = user.UserName;
var errors = new List<string>();
if (string.IsNullOrEmpty(user.UserName) || string.IsNullOrEmpty(user.Email))
{
errors.Add("Username and email are required.");
return errors;
}
if (!new EmailAddressAttribute().IsValid(user.Email))
{
errors.Add("Email is not valid.");
return errors;
}
if (isUpdate)
{
var originalUser = await userManager.FindByIdAsync(user.Id);
if (originalUser != null && user.UserName != originalUser.UserName)
{
errors.Add("Username cannot be changed for existing users.");
}
}
else
{
var existingUser = await userManager.FindByNameAsync(user.UserName);
if (existingUser != null)
{
errors.Add("Username is already in use.");
}
var existingEmail = await userManager.FindByEmailAsync(user.Email);
if (existingEmail != null)
{
errors.Add("Email is already in use.");
}
if (string.IsNullOrEmpty(password))
{
errors.Add("Password is required.");
}
}
return errors;
}
private void NotifyStateChanged() => OnChange.Invoke();
}

View File

@@ -0,0 +1,82 @@
//-----------------------------------------------------------------------
// <copyright file="StartupTasks.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin;
using AliasServerDb;
using Microsoft.AspNetCore.Identity;
/// <summary>
/// Startup tasks that should be run when the application starts.
/// </summary>
public static class StartupTasks
{
/// <summary>
/// Creates the roles if they do not exist.
/// </summary>
/// <param name="serviceProvider">IServiceProvider instance.</param>
/// <returns>Task.</returns>
public static async Task CreateRolesIfNotExist(IServiceProvider serviceProvider)
{
var roleManager = serviceProvider.GetRequiredService<RoleManager<AdminRole>>();
const string adminRole = "Admin";
if (!await roleManager.RoleExistsAsync(adminRole))
{
await roleManager.CreateAsync(new AdminRole(adminRole));
}
}
/// <summary>
/// Creates the admin user if it does not exist.
/// </summary>
/// <param name="serviceProvider">IServiceProvider instance.</param>
/// <returns>Async Task.</returns>
public static async Task SetAdminUser(IServiceProvider serviceProvider)
{
using var scope = serviceProvider.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AdminUser>>();
var adminUser = await userManager.FindByNameAsync("admin");
var config = serviceProvider.GetRequiredService<Config>();
if (adminUser == null)
{
var adminPasswordHash = config.AdminPasswordHash;
adminUser = new AdminUser();
adminUser.UserName = "admin";
await userManager.CreateAsync(adminUser);
adminUser.PasswordHash = adminPasswordHash;
adminUser.LastPasswordChanged = DateTime.UtcNow;
await userManager.UpdateAsync(adminUser);
Console.WriteLine("Admin user created.");
}
else
{
// Check if the password hash is different AND the password in .env file is newer than the password of user.
// If so, update the password hash of the user in the database so it matches the one in the .env file.
if (adminUser.PasswordHash != config.AdminPasswordHash && (adminUser.LastPasswordChanged is null || config.LastPasswordChanged > adminUser.LastPasswordChanged))
{
// The password has been changed in the .env file, update the user's password hash.
adminUser.PasswordHash = config.AdminPasswordHash;
adminUser.LastPasswordChanged = DateTime.UtcNow;
// Reset 2FA settings
adminUser.TwoFactorEnabled = false;
// Clear existing recovery codes
await userManager.GenerateNewTwoFactorRecoveryCodesAsync(adminUser, 0);
await userManager.UpdateAsync(adminUser);
Console.WriteLine("Admin password hash updated.");
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,12 @@
{
"ConnectionStrings": {
"AliasServerDbContext": "Data Source=../../database/AliasServerDb.sqlite"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

1425
src/AliasVault.Admin/package-lock.json generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "aliasvault.client",
"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"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,58 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./**/*.html',
'./**/*.razor',
'../Utilities/AliasVault.RazorComponents/**/*.razor',
],
safelist: [
'w-64',
'w-1/2',
'rounded-l-lg',
'rounded-r-lg',
'bg-gray-200',
'grid-cols-4',
'grid-cols-7',
'h-6',
'leading-6',
'h-9',
'leading-9',
'shadow-lg',
'bg-opacity-50',
'dark:bg-opacity-80'
],
darkMode: "class",
theme: {
extend: {
colors: {
primary: {
"900": "#7b4a1e",
"800": "#9a5d26",
"700": "#b8702f",
"600": "#d68338",
"500": "#f49541",
"400": "#f6a752",
"300": "#f8b963",
"200": "#fbcb74",
"100": "#fdde85",
"50": "#ffe096"
}
},
fontFamily: {
'sans': ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'system-ui', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans', 'sans-serif', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'],
'body': ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'system-ui', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans', 'sans-serif', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'],
'mono': ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace']
},
transitionProperty: {
'width': 'width'
},
textDecoration: ['active'],
minWidth: {
'kanban': '28rem'
},
},
},
plugins: [
],
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,41 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}

View File

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -0,0 +1,53 @@
function initDarkModeSwitcher() {
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
if (themeToggleDarkIcon === null && themeToggleLightIcon === null) {
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');
}
}
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')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
document.dispatchEvent(event);
});
}

View File

@@ -0,0 +1,53 @@
function downloadFileFromStream(fileName, contentStreamReference) {
const arrayBuffer = new Uint8Array(contentStreamReference).buffer;
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName ?? '';
anchorElement.click();
anchorElement.remove();
URL.revokeObjectURL(url);
}
/**
* Generate a QR code for the given id element that has a data-url attribute.
* @param id
*/
function generateQrCode(id) {
console.log(`Generating QR code for element with id "${id}".`);
// Find the element by id
const element = document.getElementById(id);
// Check if the element exists
if (!element) {
console.log(`Element with id "${id}" not found. QR code generation aborted.`);
return; // Silently fail
}
// Get the data-url attribute
const dataUrl = element.getAttribute('data-url');
// Check if data-url exists
if (!dataUrl) {
console.log(`No data-url attribute found on element with id "${id}". QR code generation aborted.`);
return; // Silently fail
}
// Create a container for the QR code
const qrContainer = document.createElement('div');
qrContainer.id = `qrcode-${id}`;
element.appendChild(qrContainer);
// Initialize QRCode object
let qrcode = new QRCode(qrContainer, {
width: 256,
height: 256,
colorDark : "#000000",
colorLight : "#ffffff",
correctLevel : QRCode.CorrectLevel.H
});
qrcode.makeCode(dataUrl);
}

View File

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,7 @@
<RootNamespace>AliasVault.Api</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DefineConstants Condition="'$(E2ETEST)' == 'true'">$(DefineConstants);E2ETEST</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
@@ -20,13 +21,13 @@
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<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="7.6.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.6.0" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.0.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -38,6 +39,7 @@
<ProjectReference Include="..\AliasGenerators\AliasGenerators.csproj" />
<ProjectReference Include="..\AliasVault.Shared\AliasVault.Shared.csproj" />
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
<ProjectReference Include="..\Utilities\Cryptography\Cryptography.csproj" />
<ProjectReference Include="..\Utilities\FaviconExtractor\FaviconExtractor.csproj" />
</ItemGroup>

View File

@@ -20,13 +20,14 @@ using Asp.Versioning;
using Cryptography.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.IdentityModel.Tokens;
/// <summary>
/// Auth controller for handling authentication.
/// </summary>
/// <param name="context">AliasServerDbContext instance.</param>
/// <param name="dbContextFactory">AliasServerDbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
/// <param name="signInManager">SignInManager instance.</param>
/// <param name="configuration">IConfiguration instance.</param>
@@ -35,7 +36,7 @@ using Microsoft.IdentityModel.Tokens;
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1")]
public class AuthController(AliasServerDbContext context, 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) : ControllerBase
{
/// <summary>
/// Error message for invalid email or password.
@@ -114,6 +115,8 @@ public class AuthController(AliasServerDbContext context, UserManager<AliasVault
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] TokenModel tokenModel)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var principal = GetPrincipalFromExpiredToken(tokenModel.Token);
if (principal.FindFirst(ClaimTypes.NameIdentifier)?.Value == null)
{
@@ -129,20 +132,20 @@ public class AuthController(AliasServerDbContext context, UserManager<AliasVault
// Check if the refresh token is valid.
// Remove any existing refresh tokens for this user and device.
var deviceIdentifier = GenerateDeviceIdentifier(Request);
var existingToken = context.AspNetUserRefreshTokens.Where(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier).FirstOrDefault();
var existingToken = context.AliasVaultUserRefreshTokens.FirstOrDefault(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier);
if (existingToken == null || existingToken.Value != tokenModel.RefreshToken || existingToken.ExpireDate < timeProvider.UtcNow)
{
return Unauthorized("Refresh token expired");
}
// Remove the existing refresh token.
context.AspNetUserRefreshTokens.Remove(existingToken);
context.AliasVaultUserRefreshTokens.Remove(existingToken);
// Generate a new refresh token to replace the old one.
var newRefreshToken = GenerateRefreshToken();
// Add new refresh token.
await context.AspNetUserRefreshTokens.AddAsync(new AspNetUserRefreshToken
await context.AliasVaultUserRefreshTokens.AddAsync(new AliasVaultUserRefreshToken
{
UserId = user.Id,
DeviceIdentifier = deviceIdentifier,
@@ -164,6 +167,8 @@ public class AuthController(AliasServerDbContext context, UserManager<AliasVault
[HttpPost("revoke")]
public async Task<IActionResult> Revoke([FromBody] TokenModel model)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var principal = GetPrincipalFromExpiredToken(model.Token);
if (principal.FindFirst(ClaimTypes.NameIdentifier)?.Value == null)
{
@@ -178,14 +183,14 @@ public class AuthController(AliasServerDbContext context, UserManager<AliasVault
// Check if the refresh token is valid.
var deviceIdentifier = GenerateDeviceIdentifier(Request);
var existingToken = context.AspNetUserRefreshTokens.Where(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier).FirstOrDefault();
var existingToken = context.AliasVaultUserRefreshTokens.FirstOrDefault(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier);
if (existingToken == null || existingToken.Value != model.RefreshToken)
{
return Unauthorized("Invalid refresh token");
}
// Remove the existing refresh token.
context.AspNetUserRefreshTokens.Remove(existingToken);
context.AliasVaultUserRefreshTokens.Remove(existingToken);
await context.SaveChangesAsync();
return Ok("Refresh token revoked successfully");
@@ -330,6 +335,8 @@ public class AuthController(AliasServerDbContext context, UserManager<AliasVault
/// <returns>TokenModel which includes new access and refresh token.</returns>
private async Task<TokenModel> GenerateNewTokensForUser(AliasVaultUser user)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var token = GenerateJwtToken(user);
var refreshToken = GenerateRefreshToken();
@@ -338,11 +345,11 @@ public class AuthController(AliasServerDbContext context, UserManager<AliasVault
// Save refresh token to database.
// Remove any existing refresh tokens for this user and device.
var existingTokens = context.AspNetUserRefreshTokens.Where(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier);
context.AspNetUserRefreshTokens.RemoveRange(existingTokens);
var existingTokens = context.AliasVaultUserRefreshTokens.Where(t => t.UserId == user.Id && t.DeviceIdentifier == deviceIdentifier);
context.AliasVaultUserRefreshTokens.RemoveRange(existingTokens);
// Add new refresh token.
await context.AspNetUserRefreshTokens.AddAsync(new AspNetUserRefreshToken
await context.AliasVaultUserRefreshTokens.AddAsync(new AliasVaultUserRefreshToken
{
UserId = user.Id,
DeviceIdentifier = deviceIdentifier,
@@ -352,6 +359,6 @@ public class AuthController(AliasServerDbContext context, UserManager<AliasVault
});
await context.SaveChangesAsync();
return new TokenModel() { Token = token, RefreshToken = refreshToken };
return new TokenModel { Token = token, RefreshToken = refreshToken };
}
}

View File

@@ -16,7 +16,7 @@ using Microsoft.EntityFrameworkCore;
/// </summary>
[ApiController]
[Route("/")]
public class RootController : ControllerBase
public class RootController(IDbContextFactory<AliasServerDbContext> dbContextFactory) : ControllerBase
{
/// <summary>
/// Root endpoint that returns a 200 OK if the database connection is successful
@@ -26,24 +26,23 @@ public class RootController : ControllerBase
[HttpGet]
[ProducesResponseType<int>(StatusCodes.Status200OK)]
[ProducesResponseType<int>(StatusCodes.Status500InternalServerError)]
public IActionResult Get()
public async Task<IActionResult> Get()
{
await using var context = await dbContextFactory.CreateDbContextAsync();
try
{
using (var dbContext = new AliasServerDbContext())
var appliedMigrations = await context.Database.GetAppliedMigrationsAsync();
var allMigrations = context.Database.GetMigrations();
if (allMigrations.Except(appliedMigrations).Any())
{
var appliedMigrations = dbContext.Database.GetAppliedMigrations();
var allMigrations = dbContext.Database.GetMigrations();
if (allMigrations.Except(appliedMigrations).Any())
{
// There are pending migrations
return StatusCode(500, "There are pending migrations. Please run 'dotnet ef database update' to apply them.");
}
// Database is up to date
return Ok("OK");
// There are pending migrations
return StatusCode(500, "There are pending migrations. Please run 'dotnet ef database update' to apply them.");
}
// Database is up to date
return Ok("OK");
}
catch
{

View File

@@ -5,10 +5,17 @@
// </copyright>
//-----------------------------------------------------------------------
/*
* Note: this file is used for E2E testing purposes only. It contains test endpoints that are called by pages on
* the client for testing purposes. Because certain endpoints that simulate exceptions are prone to Denial-Of-Service
* attack surfaces we don't include this file in the production build.
*/
namespace AliasVault.Api.Controllers;
using AliasServerDb;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -22,10 +29,22 @@ public class TestController(UserManager<AliasVaultUser> userManager) : Authentic
/// <summary>
/// Authenticated test request.
/// </summary>
/// <returns>List of aliases in JSON format.</returns>
/// <returns>Static OK.</returns>
[HttpGet("")]
public IActionResult TestCall()
{
return Ok();
}
/// <summary>
/// Test request that throws an exception. Used for testing error handling.
/// </summary>
/// <returns>Static OK.</returns>
[AllowAnonymous]
[HttpGet("Error")]
public IActionResult TestCallError()
{
// Throw an exception here to test error handling.
throw new ApplicationException("Test error");
}
}

View File

@@ -19,11 +19,11 @@ using Microsoft.EntityFrameworkCore;
/// <summary>
/// Vault controller for handling CRUD operations on the database for encrypted vault entities.
/// </summary>
/// <param name="context">DbContext instance.</param>
/// <param name="dbContextFactory">DbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
/// <param name="timeProvider">ITimeProvider instance.</param>
[ApiVersion("1")]
public class VaultController(AliasServerDbContext context, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager)
public class VaultController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Default retention policy for vaults.
@@ -46,6 +46,8 @@ public class VaultController(AliasServerDbContext context, UserManager<AliasVaul
[HttpGet("")]
public async Task<IActionResult> GetVault()
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var user = await GetCurrentUserAsync();
if (user == null)
{
@@ -76,6 +78,8 @@ public class VaultController(AliasServerDbContext context, UserManager<AliasVaul
[HttpPost("")]
public async Task<IActionResult> Update([FromBody] Shared.Models.WebApi.Vault model)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var user = await GetCurrentUserAsync();
if (user == null)
{

View File

@@ -6,9 +6,11 @@
//-----------------------------------------------------------------------
using System.Data.Common;
using System.Reflection;
using System.Text;
using AliasServerDb;
using AliasVault.Api.Jwt;
using AliasVault.Logging;
using AliasVault.Shared.Providers.Time;
using Asp.Versioning;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -19,7 +21,9 @@ using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true);
builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAssembly().GetName().Name!, "../../logs");
builder.Services.AddSingleton<ITimeProvider, SystemTimeProvider>();
builder.Services.AddScoped<TimeValidationJwtBearerEvents>();
@@ -34,18 +38,13 @@ builder.Services.AddLogging(logging =>
builder.Services.AddSingleton<DbConnection>(container =>
{
var configFile = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
var connection = new SqliteConnection(configFile.GetConnectionString("AliasServerDbContext"));
var connection = new SqliteConnection(builder.Configuration.GetConnectionString("AliasServerDbContext"));
connection.Open();
return connection;
});
builder.Services.AddDbContext<AliasServerDbContext>((container, options) =>
builder.Services.AddDbContextFactory<AliasServerDbContext>((container, options) =>
{
var connection = container.GetRequiredService<DbConnection>();
options.UseSqlite(connection).UseLazyLoadingProxies();
@@ -57,7 +56,7 @@ builder.Services.Configure<DataProtectionTokenProviderOptions>(options =>
options.TokenLifespan = TimeSpan.FromDays(30);
options.Name = "AliasVault";
});
builder.Services.AddIdentity<AliasVaultUser, IdentityRole>(options =>
builder.Services.AddIdentity<AliasVaultUser, AliasVaultRole>(options =>
{
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
@@ -92,8 +91,8 @@ builder.Services.AddAuthentication(options =>
ValidateLifetime = true,
RequireExpirationTime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Issuer"],
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Issuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ClockSkew = TimeSpan.Zero,
};
@@ -167,8 +166,11 @@ if (app.Environment.IsDevelopment())
app.UseSwagger();
app.UseSwaggerUI();
}
else
{
app.UseHttpsRedirection();
}
app.UseHttpsRedirection();
app.UseCors("CorsPolicy");
app.UseAuthentication();
@@ -179,7 +181,7 @@ app.MapControllers();
using (var scope = app.Services.CreateScope())
{
var container = scope.ServiceProvider;
var db = container.GetRequiredService<AliasServerDbContext>();
var db = await container.GetRequiredService<IDbContextFactory<AliasServerDbContext>>().CreateDbContextAsync();
await db.Database.MigrateAsync();
}

View File

@@ -1,8 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Debug"
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}
}

View File

@@ -1,8 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Default": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"Jwt": {

View File

@@ -48,10 +48,10 @@
<ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.6" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.7" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>

View File

@@ -1,5 +1,3 @@
@using AliasVault.Shared.Models.WebApi
@if (_errors.Any())
{
@foreach (var error in _errors)

View File

@@ -34,9 +34,15 @@
</div>
@code {
/// <summary>
/// Attachments to be uploaded.
/// </summary>
[Parameter]
public List<Attachment> Attachments { get; set; } = new List<Attachment>();
public List<Attachment> Attachments { get; set; } = [];
/// <summary>
/// Callback that is invoked when the attachments are changed.
/// </summary>
[Parameter]
public EventCallback<List<Attachment>> AttachmentsChanged { get; set; }

View File

@@ -35,6 +35,9 @@
</div>
@code {
/// <summary>
/// The attachments to display.
/// </summary>
[Parameter]
public ICollection<Attachment> Attachments { get; set; } = new List<Attachment>();

View File

@@ -1,7 +1,4 @@
@using AliasVault.Client.Services
<!-- CopyPasteFormRow.razor -->
@inject ClipboardCopyService ClipboardCopyService
@inject ClipboardCopyService ClipboardCopyService
@inject IJSRuntime JsRuntime
@implements IDisposable
@@ -17,12 +14,22 @@
</div>
@code {
[Parameter] public string Label { get; set; } = "Value";
[Parameter] public string Value { get; set; } = string.Empty;
/// <summary>
/// The label for the input.
/// </summary>
[Parameter]
public string Label { get; set; } = "Value";
/// <summary>
/// The value to copy to the clipboard.
/// </summary>
[Parameter]
public string Value { get; set; } = string.Empty;
private bool _copied => ClipboardCopyService.GetCopiedId() == _inputId;
private readonly string _inputId = Guid.NewGuid().ToString();
/// <inheritdoc />
protected override void OnInitialized()
{
ClipboardCopyService.OnCopy += HandleCopy;
@@ -46,6 +53,7 @@
StateHasChanged();
}
/// <inheritdoc />
public void Dispose()
{
ClipboardCopyService.OnCopy -= HandleCopy;

View File

@@ -1,6 +1,9 @@
<PageTitle>@ChildContent - AliasVault</PageTitle>
@code {
/// <summary>
/// The content to display as prefix in the page title.
/// </summary>
[Parameter]
public RenderFragment ChildContent { get; set; } = default!;
}

View File

@@ -3,7 +3,7 @@
@inherits MainBase
@inject CredentialService CredentialService
@using System.Globalization
@using AliasGenerators.Implementations
@using AliasGenerators.Password
@using AliasGenerators.Password.Implementations
@if (EditMode)

View File

@@ -38,6 +38,7 @@
private DbServiceState.DatabaseState CurrentDbState { get; set; } = new();
private const int MinimumLoadingTimeMs = 800;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
@@ -118,6 +119,7 @@
}
}
/// <inheritdoc />
public void Dispose()
{
DbService.GetState().StateChanged -= OnDatabaseStateChanged;

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