From 6cb017af1cfee3992f94c996160762e597a66db2 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 22 Jul 2024 17:14:21 +0200 Subject: [PATCH] Add admin db project password seeding logic, extended init.sh (#113) --- aliasvault.sln | 7 + docker-compose.yml | 12 + init.sh | 166 +++++- .../AliasVault.Admin2.csproj | 14 +- src/AliasVault.Admin2/Auth/Pages/Login.razor | 10 +- .../Auth/Pages/ResetPassword.razor | 10 +- src/AliasVault.Admin2/Config.cs | 26 + src/AliasVault.Admin2/Dockerfile | 48 +- .../Pages/Account/Manage/ChangePassword.razor | 7 +- src/AliasVault.Admin2/Program.cs | 21 + .../Properties/launchSettings.json | 4 +- src/AliasVault.Admin2/Services/UserService.cs | 4 +- src/AliasVault.Admin2/StartupTasks.cs | 70 +++ src/Databases/AliasServerDb/AdminUser.cs | 4 + src/Databases/AliasServerDb/Email.cs | 10 +- ...05_AddAdminUserPasswordSetDate.Designer.cs | 487 ++++++++++++++++++ ...40722144205_AddAdminUserPasswordSetDate.cs | 29 ++ .../AliasServerDbContextModelSnapshot.cs | 3 + src/Utilities/InitializationCLI/Dockerfile | 26 + .../InitializationCLI.csproj | 21 + src/Utilities/InitializationCLI/Program.cs | 17 + 21 files changed, 945 insertions(+), 51 deletions(-) create mode 100644 src/AliasVault.Admin2/Config.cs create mode 100644 src/AliasVault.Admin2/StartupTasks.cs create mode 100644 src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.Designer.cs create mode 100644 src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.cs create mode 100644 src/Utilities/InitializationCLI/Dockerfile create mode 100644 src/Utilities/InitializationCLI/InitializationCLI.csproj create mode 100644 src/Utilities/InitializationCLI/Program.cs diff --git a/aliasvault.sln b/aliasvault.sln index 57a9b270f..1c40be0fa 100644 --- a/aliasvault.sln +++ b/aliasvault.sln @@ -45,6 +45,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Admin", "src\Ali EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Admin2", "src\AliasVault.Admin2\AliasVault.Admin2.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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,6 +117,10 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -131,6 +137,7 @@ 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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FEE82475-C009-4762-8113-A6563D9DC49E} diff --git a/docker-compose.yml b/docker-compose.yml index 2a8e5c76e..d698f9783 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,16 @@ services: + admin: + image: aliasvault-admin + build: + context: . + dockerfile: src/AliasVault.Admin2/Dockerfile + ports: + - "8080:8082" + volumes: + - ./database:/database + restart: always + env_file: + - .env client: image: aliasvault-client build: diff --git a/init.sh b/init.sh index 560b99198..12ce7c24f 100755 --- a/init.sh +++ b/init.sh @@ -13,6 +13,25 @@ NC='\033[0m' # No Color ENV_FILE=".env" ENV_EXAMPLE_FILE=".env.example" +# Define verbose flag +VERBOSE=false + +# Function to parse command-line arguments +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + --verbose) + VERBOSE=true + ;; + *) + printf "${RED}Unknown argument: $1${NC}\n" + exit 1 + ;; + esac + shift + done +} + # 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 @@ -87,6 +106,125 @@ set_smtp_tls_enabled() { fi } +# Function to generate a random admin password and store its hash in the .env file with progress indication +generate_admin_password() { + if grep -q "^ADMIN_PASSWORD_HASH=" ".env"; then + ADMIN_PASSWORD_GENERATED=$(grep "^ADMIN_PASSWORD_GENERATED=" ".env" | cut -d '=' -f2) + + printf "${CYAN}> ADMIN_PASSWORD_HASH already exists in .env. Last generated at ${ADMIN_PASSWORD_GENERATED}.${NC}\n" + + printf "\n" + read -p " Do you want to update the admin password? (yes/no): " confirm + if [[ "$confirm" != "yes" ]]; then + printf "${CYAN}> Admin password will not be changed.${NC}\n" + return 0 + fi + + # Remove existing entries + sed -i '' '/^ADMIN_PASSWORD_HASH=/d' .env + sed -i '' '/^ADMIN_PASSWORD_GENERATED=/d' .env + fi + + ADMIN_PASSWORD=$(openssl rand -base64 12) + printf "\n" + printf "${BLUE} Building Docker image for password generation...${NC}\n" + if [ "$VERBOSE" = true ]; then + docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile . + else + { + docker build -t initcli -f src/Utilities/InitializationCLI/Dockerfile . | while IFS= read -r line; do + printf "." + done + } > init_build_output.log 2>&1 & + BUILD_PID=$! + while kill -0 $BUILD_PID 2> /dev/null; do + printf "." + sleep 1 + done + wait $BUILD_PID + BUILD_EXIT_CODE=$? + if [ $BUILD_EXIT_CODE -ne 0 ]; then + printf "\n${RED} Error occurred while building Docker image:${NC}\n" + cat init_build_output.log + return 1 + fi + fi + + printf "\n" + printf "${BLUE} Running Docker container to generate admin password hash...${NC}\n" + { + ADMIN_PASSWORD_HASH=$(docker run --rm initcli "$ADMIN_PASSWORD") + } &> init_run_output.log + if [ $? -ne 0 ]; then + printf "${RED} Error occurred while running Docker container:${NC}\n" + cat init_run_output.log + return 1 + fi + + CURRENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Append new entries + echo "ADMIN_PASSWORD_HASH=$ADMIN_PASSWORD_HASH" >> .env + echo "ADMIN_PASSWORD_GENERATED=$CURRENT_TIME" >> .env + + printf "\n" + printf "${CYAN}> New admin password generated successfully.${NC}\n" +} + +# 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 "\n" + printf "${BLUE}Building Docker Compose stack...${NC}\n" + if [ "$VERBOSE" = true ]; then + docker-compose build + else + { + docker-compose build | while IFS= read -r line; do + printf "." + done + } > compose_build_output.log 2>&1 & + BUILD_PID=$! + while kill -0 $BUILD_PID 2> /dev/null; do + printf "." + sleep 1 + done + wait $BUILD_PID + BUILD_EXIT_CODE=$? + if [ $BUILD_EXIT_CODE -ne 0 ]; then + printf "\n${RED}Error occurred while building Docker Compose stack:${NC}\n" + cat compose_build_output.log + return 1 + fi + fi + + printf "\n" + printf "\n${BLUE}Starting Docker Compose stack...${NC}\n" + if [ "$VERBOSE" = true ]; then + docker-compose up -d + else + { + docker-compose up -d | while IFS= read -r line; do + printf "." + done + } > compose_up_output.log 2>&1 & + UP_PID=$! + while kill -0 $UP_PID 2> /dev/null; do + printf "." + sleep 1 + done + wait $UP_PID + UP_EXIT_CODE=$? + if [ $UP_EXIT_CODE -ne 0 ]; then + printf "\n${RED}Error occurred while starting Docker Compose stack:${NC}\n" + cat compose_up_output.log + return 1 + fi + fi + + printf "\n${GREEN}Docker Compose stack built and started successfully.${NC}\n" +} + + # Function to print the CLI logo print_logo() { printf "${MAGENTA}\n" @@ -104,15 +242,35 @@ print_logo() { # Run the functions and print status print_logo -printf "${BLUE}Initializing AliasVault...${NC}\n" +printf "${BLUE}+++ Initializing .env file...${NC}\n" +printf "\n" create_env_file populate_jwt_key set_smtp_allowed_domains set_smtp_tls_enabled -printf "${BLUE}Initialization complete.${NC}\n" +generate_admin_password +printf "\n${BLUE}+++ Finish initializing .env file...${NC}\n" +build_and_run_docker_compose +printf "${BLUE}If no errors are reported, the AliasVault Docker containers should have started successfully.${NC}\n" printf "\n" -printf "To build the images and start the containers, run the following command:\n" +printf "${MAGENTA}=========================================================${NC}\n" printf "\n" -printf "${CYAN}$ docker compose up -d --build --force-recreate${NC}\n" +printf "AliasVault is successfully initialized!\n" printf "\n" +printf "You can now login to the admin panel:\n" printf "\n" +if [ "$ADMIN_PASSWORD" != "" ]; then + printf "${CYAN}Admin Panel: http://localhost:8080/${NC}\n" + printf "${CYAN}Username: admin${NC}\n" + printf "${CYAN}Password: $ADMIN_PASSWORD${NC}\n" + printf "\n" + printf "(!) Caution: Make sure to backup the above credentials in a safe place, they won't be shown again!\n" + printf "\n" +else + printf "${CYAN}Admin Panel: http://localhost:8080/${NC}\n" + printf "${CYAN}Username: admin${NC}\n" + printf "${CYAN}Password: (Previously set.)${NC}\n" + printf "\n" + printf "\n" +fi + diff --git a/src/AliasVault.Admin2/AliasVault.Admin2.csproj b/src/AliasVault.Admin2/AliasVault.Admin2.csproj index ff83bb5d5..63a76888f 100644 --- a/src/AliasVault.Admin2/AliasVault.Admin2.csproj +++ b/src/AliasVault.Admin2/AliasVault.Admin2.csproj @@ -8,6 +8,14 @@ Linux + + bin\Debug\net8.0\AliasVault.Admin2.xml + + + + bin\Release\net8.0\AliasVault.Admin2.xml + + @@ -26,6 +34,9 @@ + + stylecop.json + @@ -52,13 +63,10 @@ - - - diff --git a/src/AliasVault.Admin2/Auth/Pages/Login.razor b/src/AliasVault.Admin2/Auth/Pages/Login.razor index a05b71238..df3e1ea1b 100644 --- a/src/AliasVault.Admin2/Auth/Pages/Login.razor +++ b/src/AliasVault.Admin2/Auth/Pages/Login.razor @@ -11,9 +11,9 @@
- - - + + +
@@ -56,7 +56,7 @@ { ServerValidationErrors.Clear(); - var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: true); + var result = await SignInManager.PasswordSignInAsync(Input.UserName, Input.Password, Input.RememberMe, lockoutOnFailure: true); if (result.Succeeded) { Logger.LogInformation("User logged in."); @@ -81,7 +81,7 @@ private sealed class InputModel { - [Required] [EmailAddress] public string Email { get; set; } = ""; + [Required] public string UserName { get; set; } = ""; [Required] [DataType(DataType.Password)] diff --git a/src/AliasVault.Admin2/Auth/Pages/ResetPassword.razor b/src/AliasVault.Admin2/Auth/Pages/ResetPassword.razor index 134d7afb8..cfe561fd7 100644 --- a/src/AliasVault.Admin2/Auth/Pages/ResetPassword.razor +++ b/src/AliasVault.Admin2/Auth/Pages/ResetPassword.razor @@ -17,9 +17,9 @@
- - - + + +
@@ -57,7 +57,7 @@ private async Task OnValidSubmitAsync() { - var user = await UserManager.FindByEmailAsync(Input.Email); + var user = await UserManager.FindByNameAsync(Input.UserName); if (user is null) { // Don't reveal that the user does not exist @@ -75,7 +75,7 @@ private sealed class InputModel { - [Required] [EmailAddress] public string Email { get; set; } = ""; + [Required] public string UserName { get; set; } = ""; [Required] [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] diff --git a/src/AliasVault.Admin2/Config.cs b/src/AliasVault.Admin2/Config.cs new file mode 100644 index 000000000..a6d4a4853 --- /dev/null +++ b/src/AliasVault.Admin2/Config.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin2; + +/// +/// Configuration class for the Admin project with values loaded from environment variables. +/// +public class Config +{ + /// + /// Gets or sets the admin password hash which is generated by init.sh and will be set + /// as the default password for the admin user. + /// + public string AdminPasswordHash { get; set; } = "false"; + + /// + /// Gets or sets the last time the password was changed. This is used to check if the + /// password hash generated by init.sh should replace the current password hash if user already exists. + /// + public DateTime LastPasswordChanged { get; set; } = DateTime.MinValue; +} diff --git a/src/AliasVault.Admin2/Dockerfile b/src/AliasVault.Admin2/Dockerfile index 4b88cb594..05f769aaf 100644 --- a/src/AliasVault.Admin2/Dockerfile +++ b/src/AliasVault.Admin2/Dockerfile @@ -1,23 +1,33 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base -USER $APP_UID -WORKDIR /app -EXPOSE 8080 -EXPOSE 8081 - -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -ARG BUILD_CONFIGURATION=Release +# Use the official .NET 8 SDK image to build the app +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src -COPY ["src/AliasVault.Admin2/AliasVault.Admin2.csproj", "src/AliasVault.Admin2/"] -RUN dotnet restore "src/AliasVault.Admin2/AliasVault.Admin2.csproj" -COPY . . -WORKDIR "/src/src/AliasVault.Admin2" -RUN dotnet build "AliasVault.Admin2.csproj" -c $BUILD_CONFIGURATION -o /app/build -FROM build AS publish -ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "AliasVault.Admin2.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +# Copy the solution file to the /src directory in the container +COPY aliasvault.sln ./ + +# Copy the project file to the /src/AliasVault directory in the container +COPY src/AliasVault.Admin2/AliasVault.Admin2.csproj ./AliasVault.Admin2/ + +# Restore dependencies for the AliasVault project +RUN dotnet restore "./AliasVault.Admin2/AliasVault.Admin2.csproj" --verbosity detailed + +# Copy the rest of the application code to the /src directory in the container +COPY src/. ./ + +# Publish the application to the /app/publish directory in the container +WORKDIR /src/AliasVault.Admin2 +RUN dotnet publish -c Release -o /app --verbosity detailed + +# Use the official ASP.NET Core runtime image to run the app +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app/AliasVault.Admin2 + +# Copy the published output from the build stage to the runtime stage +COPY --from=build /app ./ + +# Expose the port the app runs on +EXPOSE 8082 +ENV ASPNETCORE_URLS=http://+:8082 -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "AliasVault.Admin2.dll"] + diff --git a/src/AliasVault.Admin2/Main/Pages/Account/Manage/ChangePassword.razor b/src/AliasVault.Admin2/Main/Pages/Account/Manage/ChangePassword.razor index f623443dd..ce47ed85d 100644 --- a/src/AliasVault.Admin2/Main/Pages/Account/Manage/ChangePassword.razor +++ b/src/AliasVault.Admin2/Main/Pages/Account/Manage/ChangePassword.razor @@ -10,7 +10,6 @@

Change password

- @@ -38,7 +37,6 @@
@code { - private string? message; private bool hasPassword; [CascadingParameter] private HttpContext HttpContext { get; set; } = default!; @@ -57,9 +55,12 @@ 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); if (!changePasswordResult.Succeeded) { - message = $"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}"; + GlobalNotificationService.AddErrorMessage($"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}"); return; } diff --git a/src/AliasVault.Admin2/Program.cs b/src/AliasVault.Admin2/Program.cs index 920bc8878..cbc115c66 100644 --- a/src/AliasVault.Admin2/Program.cs +++ b/src/AliasVault.Admin2/Program.cs @@ -1,5 +1,7 @@ using System.Data.Common; +using System.Globalization; using AliasServerDb; +using AliasVault.Admin2; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -10,6 +12,18 @@ using Microsoft.Data.Sqlite; var builder = WebApplication.CreateBuilder(args); +// 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(); @@ -64,6 +78,7 @@ builder.Services.AddIdentityCore(options => options.Password.RequiredLength = 8; options.Password.RequiredUniqueChars = 0; options.SignIn.RequireConfirmedAccount = false; + options.User.RequireUniqueEmail = false; }) .AddRoles() .AddEntityFrameworkStores() @@ -92,4 +107,10 @@ app.UseAntiforgery(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); +using (var scope = app.Services.CreateScope()) +{ + await StartupTasks.CreateRolesIfNotExist(scope.ServiceProvider); + await StartupTasks.SetAdminUser(scope.ServiceProvider); +} + app.Run(); diff --git a/src/AliasVault.Admin2/Properties/launchSettings.json b/src/AliasVault.Admin2/Properties/launchSettings.json index 678da3f00..466e0c11a 100644 --- a/src/AliasVault.Admin2/Properties/launchSettings.json +++ b/src/AliasVault.Admin2/Properties/launchSettings.json @@ -15,7 +15,9 @@ "launchBrowser": true, "applicationUrl": "http://localhost:5216", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "ADMIN_PASSWORD_HASH": "AQAAAAIAAYagAAAAEKWfKfa2gh9Z72vjAlnNP1xlME7FsunRznzyrfqFte40FToufRwa3kX8wwDwnEXZag==", + "ADMIN_PASSWORD_GENERATED": "2024-07-22T16:36:02Z" } }, "https": { diff --git a/src/AliasVault.Admin2/Services/UserService.cs b/src/AliasVault.Admin2/Services/UserService.cs index f0f269efe..11c709576 100644 --- a/src/AliasVault.Admin2/Services/UserService.cs +++ b/src/AliasVault.Admin2/Services/UserService.cs @@ -187,9 +187,9 @@ public class UserService /// Update user. /// /// User object. - /// New password for the user. + /// Optional parameter for new password for the user. /// List of errors if any. - public async Task> UpdateUserAsync(AdminUser user, string newPassword) + public async Task> UpdateUserAsync(AdminUser user, string newPassword = "") { var errors = await ValidateUser(user, newPassword, isUpdate: true); if (errors.Count > 0) diff --git a/src/AliasVault.Admin2/StartupTasks.cs b/src/AliasVault.Admin2/StartupTasks.cs new file mode 100644 index 000000000..7ac25c1c1 --- /dev/null +++ b/src/AliasVault.Admin2/StartupTasks.cs @@ -0,0 +1,70 @@ +namespace AliasVault.Admin2; + +using AliasServerDb; +using Microsoft.AspNetCore.Identity; + +/// +/// Startup tasks that should be run when the application starts. +/// +public static class StartupTasks +{ + /// + /// Creates the roles if they do not exist. + /// + /// IServiceProvider instance. + /// Task. + public static async Task CreateRolesIfNotExist(IServiceProvider serviceProvider) + { + var roleManager = serviceProvider.GetRequiredService>(); + + const string adminRole = "Admin"; + + if (!await roleManager.RoleExistsAsync(adminRole)) + { + await roleManager.CreateAsync(new AdminRole(adminRole)); + } + } + + /// + /// Creates the admin user if it does not exist. + /// + /// + /// Async Task. + public static async Task SetAdminUser(IServiceProvider serviceProvider) + { + using var scope = serviceProvider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var adminUser = await userManager.FindByNameAsync("admin"); + var config = serviceProvider.GetRequiredService(); + + if (adminUser == null) + { + var adminPasswordHash = config.AdminPasswordHash; + adminUser = new AdminUser(); + adminUser.UserName = "admin"; + + await userManager.CreateAsync(adminUser); + adminUser.PasswordHash = adminPasswordHash; + await userManager.UpdateAsync(adminUser); + + Console.WriteLine("Admin user created."); + } + else + { + // Check if the password hash is correct, if not, update it to the .env value. + if (adminUser.PasswordHash != config.AdminPasswordHash) + { + if (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; + await userManager.UpdateAsync(adminUser); + + Console.WriteLine("Admin password hash updated."); + } + } + } + } +} diff --git a/src/Databases/AliasServerDb/AdminUser.cs b/src/Databases/AliasServerDb/AdminUser.cs index f87782534..08aa690f1 100644 --- a/src/Databases/AliasServerDb/AdminUser.cs +++ b/src/Databases/AliasServerDb/AdminUser.cs @@ -14,4 +14,8 @@ using Microsoft.AspNetCore.Identity; /// public class AdminUser : IdentityUser { + /// + /// Gets or sets the last time the password was changed. + /// + public DateTime? LastPasswordChanged { get; set; } } diff --git a/src/Databases/AliasServerDb/Email.cs b/src/Databases/AliasServerDb/Email.cs index daf1f95b5..e5fd7f565 100644 --- a/src/Databases/AliasServerDb/Email.cs +++ b/src/Databases/AliasServerDb/Email.cs @@ -19,14 +19,6 @@ using Microsoft.EntityFrameworkCore; [Index(nameof(PushNotificationSent))] public class Email { - /// - /// Initializes a new instance of the class. - /// - public Email() - { - Attachments = new HashSet(); - } - /// /// Gets or sets the ID of the email. /// @@ -110,5 +102,5 @@ public class Email /// /// Gets or sets the collection of email attachments. /// - public virtual ICollection Attachments { get; set; } + public virtual ICollection Attachments { get; set; } = []; } diff --git a/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.Designer.cs new file mode 100644 index 000000000..d66644047 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.Designer.cs @@ -0,0 +1,487 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20240722144205_AddAdminUserPasswordSetDate")] + partial class AddAdminUserPasswordSetDate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("AliasServerDb.AdminRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AdminUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordChanged") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AdminUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultRoles"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("Verifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AliasVaultUsers"); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AliasVaultUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateSystem") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MessageHtml") + .HasColumnType("TEXT"); + + b.Property("MessagePlain") + .HasColumnType("TEXT"); + + b.Property("MessagePreview") + .HasColumnType("TEXT"); + + b.Property("MessageSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PushNotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToDomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ToLocal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DateSystem"); + + b.HasIndex("PushNotificationSent"); + + b.HasIndex("ToLocal"); + + b.HasIndex("Visible"); + + b.ToTable("Emails"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EmailId") + .HasColumnType("INTEGER"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Filesize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.ToTable("EmailAttachments"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.EmailAttachment", b => + { + b.HasOne("AliasServerDb.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Email", b => + { + b.Navigation("Attachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.cs b/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.cs new file mode 100644 index 000000000..2f1a4faa7 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240722144205_AddAdminUserPasswordSetDate.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class AddAdminUserPasswordSetDate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastPasswordChanged", + table: "AdminUsers", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastPasswordChanged", + table: "AdminUsers"); + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index 8c77f11ae..ccbac66e9 100644 --- a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -57,6 +57,9 @@ namespace AliasServerDb.Migrations b.Property("EmailConfirmed") .HasColumnType("INTEGER"); + b.Property("LastPasswordChanged") + .HasColumnType("TEXT"); + b.Property("LockoutEnabled") .HasColumnType("INTEGER"); diff --git a/src/Utilities/InitializationCLI/Dockerfile b/src/Utilities/InitializationCLI/Dockerfile new file mode 100644 index 000000000..82bd26480 --- /dev/null +++ b/src/Utilities/InitializationCLI/Dockerfile @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +# Copy csproj files and restore as distinct layers +COPY ["src/Utilities/InitializationCLI/InitializationCLI.csproj", "src/Utilities/InitializationCLI/"] +COPY ["src/Databases/AliasServerDb/AliasServerDb.csproj", "src/Databases/AliasServerDb/"] +RUN dotnet restore "src/Utilities/InitializationCLI/InitializationCLI.csproj" + +# Copy the entire source code +COPY . . + +# Build the project +RUN dotnet build "src/Utilities/InitializationCLI/InitializationCLI.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "src/Utilities/InitializationCLI/InitializationCLI.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "InitializationCLI.dll"] diff --git a/src/Utilities/InitializationCLI/InitializationCLI.csproj b/src/Utilities/InitializationCLI/InitializationCLI.csproj new file mode 100644 index 000000000..7725161a9 --- /dev/null +++ b/src/Utilities/InitializationCLI/InitializationCLI.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + enable + + + + + .dockerignore + Dockerfile + + + + + + + + diff --git a/src/Utilities/InitializationCLI/Program.cs b/src/Utilities/InitializationCLI/Program.cs new file mode 100644 index 000000000..5d744bdc3 --- /dev/null +++ b/src/Utilities/InitializationCLI/Program.cs @@ -0,0 +1,17 @@ +// See https://aka.ms/new-console-template for more information +using System; +using AliasServerDb; +using Microsoft.AspNetCore.Identity; + +if (args.Length == 0) +{ + Console.WriteLine("Please provide a password as an argument."); + return; +} + +var password = args[0]; +var hasher = new PasswordHasher(); +var user = new AdminUser(); +var hashedPassword = hasher.HashPassword(user, password); + +Console.WriteLine($"{hashedPassword}");