diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 943f27e3d..9f4eefdb3 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -95,6 +95,51 @@ jobs: fi echo "✅ Admin endpoint (/admin) returned HTTP 200" + - name: Verify admin password hash file does not exist initially + run: | + if [ -f "./secrets/admin_password_hash" ]; then + echo "❌ Admin password hash file should not exist initially" + cat ./secrets/admin_password_hash + exit 1 + fi + echo "✅ Admin password hash file correctly does not exist initially" + + - name: Test admin password reset flow + run: | + echo "🔧 Testing admin password reset flow..." + + # Run the reset password script with auto-confirm + echo "Running reset-admin-password.sh script..." + password_output=$(docker exec aliasvault-test reset-admin-password.sh -y 2>&1) + echo "Script output:" + echo "$password_output" + + # Extract the generated password from the output + generated_password=$(echo "$password_output" | grep -E "^Password: " | sed 's/Password: //') + if [ -z "$generated_password" ]; then + echo "❌ Failed to extract generated password from script output" + echo "Full output was:" + echo "$password_output" + exit 1 + fi + echo "✅ Generated password extracted: $generated_password" + + # Verify that the admin_password_hash file now exists in the container + if ! docker exec aliasvault-test test -f /secrets/admin_password_hash; then + echo "❌ Admin password hash file was not created in container" + docker exec aliasvault-test ls -la /secrets/ + exit 1 + fi + echo "✅ Admin password hash file created in container" + + # Verify that the admin_password_hash file exists locally (mounted volume) + if [ ! -f "./secrets/admin_password_hash" ]; then + echo "❌ Admin password hash file not found in local secrets folder" + ls -la ./secrets/ + exit 1 + fi + echo "✅ Admin password hash file exists in local secrets folder" + - name: Test SMTP port uses: nick-fields/retry@v3 with: diff --git a/apps/server/AliasVault.Admin/Auth/Pages/Login.razor b/apps/server/AliasVault.Admin/Auth/Pages/Login.razor index 59be95b5d..c08653571 100644 --- a/apps/server/AliasVault.Admin/Auth/Pages/Login.razor +++ b/apps/server/AliasVault.Admin/Auth/Pages/Login.razor @@ -9,31 +9,60 @@ - - -
- - - -
-
- - - -
- -
-
- +@if (!IsAdminConfigured) +{ +
+
+
+ + + +
+
+

+ Admin User Not Configured +

+
+

The admin user has not been configured yet. To set up admin access:

+
    +
  1. Connect to your Docker container: docker exec -it [container-name] /bin/bash
  2. +
  3. Run the password reset script: reset-admin-password.sh
  4. +
  5. Restart the container to apply changes: docker restart [container-name]
  6. +
+

Replace [container-name] with your actual container name, e.g. "aliasvault".

+
+
-
- -
- Lost Password?
+} +else +{ + + +
+ + + +
+
+ + + +
- -
+
+
+ +
+
+ +
+ Lost Password? +
+ + + +} @code { @@ -43,10 +72,17 @@ [SupplyParameterFromQuery] private string? ReturnUrl { get; set; } + private bool IsAdminConfigured { get; set; } = true; + /// protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); + + // Check if admin user exists + var adminUser = await UserManager.FindByNameAsync("admin"); + IsAdminConfigured = adminUser != null; + if (HttpMethods.IsGet(HttpContext.Request.Method)) { // Clear the existing external cookie to ensure a clean login process diff --git a/apps/server/AliasVault.Admin/Program.cs b/apps/server/AliasVault.Admin/Program.cs index 6a8bba222..e539fd763 100644 --- a/apps/server/AliasVault.Admin/Program.cs +++ b/apps/server/AliasVault.Admin/Program.cs @@ -34,9 +34,19 @@ builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAs var config = new Config(); // Get admin password hash and generation timestamp using SecretReader -var (adminPasswordHash, lastPasswordChanged) = SecretReader.GetAdminPasswordHash(); -config.AdminPasswordHash = adminPasswordHash; -config.LastPasswordChanged = lastPasswordChanged; +// If the admin password hash file doesn't exist, leave config values empty (admin user won't be created) +try +{ + var (adminPasswordHash, lastPasswordChanged) = SecretReader.GetAdminPasswordHash(); + config.AdminPasswordHash = adminPasswordHash; + config.LastPasswordChanged = lastPasswordChanged; +} +catch (KeyNotFoundException) +{ + // Admin password hash not configured - this is expected when no password has been set yet + config.AdminPasswordHash = string.Empty; + config.LastPasswordChanged = DateTime.MinValue; +} var ipLoggingEnabled = Environment.GetEnvironmentVariable("IP_LOGGING_ENABLED") ?? "false"; config.IpLoggingEnabled = bool.Parse(ipLoggingEnabled); diff --git a/apps/server/AliasVault.Admin/StartupTasks.cs b/apps/server/AliasVault.Admin/StartupTasks.cs index db8d37c46..9d7d18229 100644 --- a/apps/server/AliasVault.Admin/StartupTasks.cs +++ b/apps/server/AliasVault.Admin/StartupTasks.cs @@ -33,7 +33,7 @@ public static class StartupTasks } /// - /// Creates the admin user if it does not exist. + /// Creates the admin user if it does not exist and admin password hash is configured. /// /// IServiceProvider instance. /// Async Task. @@ -44,6 +44,14 @@ public static class StartupTasks var adminUser = await userManager.FindByNameAsync("admin"); var config = serviceProvider.GetRequiredService(); + // Skip admin user creation if no admin password hash is configured + if (string.IsNullOrEmpty(config.AdminPasswordHash)) + { + Console.WriteLine("Admin password hash not configured - skipping admin user creation."); + Console.WriteLine("Run 'reset-admin-password.sh' to configure the admin password."); + return; + } + if (adminUser == null) { var adminPasswordHash = config.AdminPasswordHash; @@ -59,9 +67,9 @@ public static class StartupTasks } 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)) + // Check if the password hash is different AND the hash in secret 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 admin_password_hash file. + if (adminUser.PasswordHash != config.AdminPasswordHash && (adminUser.LastPasswordChanged is null || (config.LastPasswordChanged != DateTime.MinValue && config.LastPasswordChanged > adminUser.LastPasswordChanged))) { // The password has been changed in the .env file, update the user's password hash. adminUser.PasswordHash = config.AdminPasswordHash; diff --git a/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css b/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css index 849d29f81..5e891aa21 100644 --- a/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css +++ b/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css @@ -554,6 +554,40 @@ video { --tw-contain-style: ; } +.container { + width: 100%; +} + +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} + +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} + .sr-only { position: absolute; width: 1px; @@ -978,6 +1012,10 @@ video { cursor: pointer; } +.list-inside { + list-style-position: inside; +} + .list-decimal { list-style-type: decimal; } @@ -1245,6 +1283,11 @@ video { border-color: rgb(234 179 8 / var(--tw-border-opacity)); } +.border-yellow-200 { + --tw-border-opacity: 1; + border-color: rgb(254 240 138 / var(--tw-border-opacity)); +} + .bg-blue-100 { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity)); @@ -1794,6 +1837,11 @@ video { color: rgb(133 77 14 / var(--tw-text-opacity)); } +.text-yellow-600 { + --tw-text-opacity: 1; + color: rgb(202 138 4 / var(--tw-text-opacity)); +} + .underline { text-decoration-line: underline; } @@ -2092,6 +2140,11 @@ video { border-color: rgb(234 179 8 / var(--tw-border-opacity)); } +.dark\:border-yellow-800:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(133 77 14 / var(--tw-border-opacity)); +} + .dark\:bg-blue-800:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(30 64 175 / var(--tw-bg-opacity)); @@ -2183,6 +2236,10 @@ video { background-color: rgb(113 63 18 / var(--tw-bg-opacity)); } +.dark\:bg-yellow-900\/20:is(.dark *) { + background-color: rgb(113 63 18 / 0.2); +} + .dark\:bg-opacity-80:is(.dark *) { --tw-bg-opacity: 0.8; } @@ -2282,6 +2339,16 @@ video { color: rgb(254 240 138 / var(--tw-text-opacity)); } +.dark\:text-yellow-300:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(253 224 71 / var(--tw-text-opacity)); +} + +.dark\:text-yellow-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(250 204 21 / var(--tw-text-opacity)); +} + .dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder { --tw-placeholder-opacity: 1; color: rgb(156 163 175 / var(--tw-placeholder-opacity)); diff --git a/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/UserManagementTests.cs b/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/UserManagementTests.cs index 5e9a4bcc0..a435a8cd8 100644 --- a/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/UserManagementTests.cs +++ b/apps/server/Tests/AliasVault.E2ETests/Tests/Admin/UserManagementTests.cs @@ -174,10 +174,6 @@ public class UserManagementTests : AdminPlaywrightTest // Wait for the user details page to load await WaitForUrlAsync($"users/{_testUserId}**", _testUserEmail); - // Verify we're on the correct user's page - var pageContent = await Page.TextContentAsync("body"); - Assert.That(pageContent, Does.Contain(_testUserEmail), "Test user email should be visible on the user details page"); - // Click the edit username button (the SVG edit icon) var editButton = Page.Locator("button[id='edit-username-button']"); await editButton.ClickAsync(); @@ -186,7 +182,7 @@ public class UserManagementTests : AdminPlaywrightTest await Page.WaitForSelectorAsync("text=Change Username"); // Verify the form appeared - pageContent = await Page.TextContentAsync("body"); + var pageContent = await Page.TextContentAsync("body"); Assert.That(pageContent, Does.Contain("Change Username"), "Username change form should appear after clicking edit button"); Assert.That(pageContent, Does.Contain("Changing a username is permanent"), "Warning message should be visible"); diff --git a/dockerfiles/all-in-one/Dockerfile b/dockerfiles/all-in-one/Dockerfile index 77cfd687c..1c9a9ffde 100644 --- a/dockerfiles/all-in-one/Dockerfile +++ b/dockerfiles/all-in-one/Dockerfile @@ -106,6 +106,10 @@ COPY --from=dotnet-builder /app/installcli /usr/local/bin/aliasvault-cli RUN chmod +x /usr/local/bin/aliasvault-cli/AliasVault.InstallCli && \ ln -s /usr/local/bin/aliasvault-cli/AliasVault.InstallCli /usr/local/bin/aliasvault-cli.sh +# Copy password reset script and make it executable +COPY dockerfiles/all-in-one/reset-admin-password.sh /usr/local/bin/reset-admin-password.sh +RUN chmod +x /usr/local/bin/reset-admin-password.sh + # Copy client nginx configuration and ensure wwwroot is accessible COPY apps/server/AliasVault.Client/nginx.conf /app/client/nginx.conf diff --git a/dockerfiles/all-in-one/reset-admin-password.sh b/dockerfiles/all-in-one/reset-admin-password.sh new file mode 100755 index 000000000..d1ce7be95 --- /dev/null +++ b/dockerfiles/all-in-one/reset-admin-password.sh @@ -0,0 +1,182 @@ +#!/bin/bash + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Default values +CONFIRM_RESET=false +PASSWORD_LENGTH=16 + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -y|--yes) + CONFIRM_RESET=true + shift + ;; + -l|--length) + PASSWORD_LENGTH="$2" + shift 2 + ;; + -h|--help) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Reset the admin password with a randomly generated password" + echo "" + echo "OPTIONS:" + echo " -y, --yes Skip confirmation prompt" + echo " -l, --length NUM Password length (default: 16)" + echo " -h, --help Show this help message" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "Use -h or --help for usage information" + exit 1 + ;; + esac +done + +# Function to generate a secure random password +generate_password() { + local length=$1 + # Generate password with uppercase, lowercase, numbers, and special characters + # Using /dev/urandom for cryptographically secure randomness + local password=$(tr -dc 'A-Za-z0-9!@#$%^&*()_+=-' < /dev/urandom | head -c "$length") + echo "$password" +} + +# Function to hash the password using the aliasvault-cli +hash_password() { + local password=$1 + local hash + + # Check if aliasvault-cli.sh exists + if [ ! -f /usr/local/bin/aliasvault-cli.sh ] && [ ! -L /usr/local/bin/aliasvault-cli.sh ]; then + echo -e "${RED}Error: aliasvault-cli.sh not found${NC}" >&2 + return 1 + fi + + # Hash the password + hash=$(/usr/local/bin/aliasvault-cli.sh hash-password "$password" 2>/dev/null) + + if [ $? -ne 0 ] || [ -z "$hash" ]; then + echo -e "${RED}Error: Failed to hash password${NC}" >&2 + return 1 + fi + + echo "$hash" +} + +# Function to update the admin password hash file +update_hash_file() { + local hash=$1 + local hash_file="/secrets/admin_password_hash" + + # Create /secrets directory if it doesn't exist + if [ ! -d "/secrets" ]; then + mkdir -p /secrets + if [ $? -ne 0 ]; then + echo -e "${RED}Error: Failed to create /secrets directory${NC}" >&2 + return 1 + fi + fi + + # Get current timestamp in ISO8601 format (UTC) + local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Write hash and timestamp to file + cat > "$hash_file" <&2 + return 1 + fi + + # Set appropriate permissions (readable by the application, not world-readable) + chmod 600 "$hash_file" + + echo -e "${GREEN}Password hash updated successfully${NC}" + echo -e "Hash file: $hash_file" + echo -e "Updated at: $timestamp" + + return 0 +} + +# Main execution +main() { + echo -e "${YELLOW}=== AliasVault Admin Password Reset ===${NC}" + echo "" + + # Check if running in Docker container + if [ ! -f /.dockerenv ] && [ ! -f /run/.containerenv ]; then + echo -e "${YELLOW}Warning: This script appears to be running outside of a Docker container${NC}" + echo -e "${YELLOW}The password hash file will be created at: /secrets/admin_password_hash${NC}" + echo "" + fi + + # Confirmation prompt + if [ "$CONFIRM_RESET" = false ]; then + echo -e "${YELLOW}This will reset the admin password with a new randomly generated password.${NC}" + echo -e "${YELLOW}The current admin password (if any) will be permanently overwritten.${NC}" + echo "" + read -p "Are you sure you want to reset the admin password? (yes/no): " confirm + + if [[ ! "$confirm" =~ ^[Yy]([Ee][Ss])?$ ]]; then + echo -e "${RED}Password reset cancelled${NC}" + exit 0 + fi + fi + + echo "" + echo "Generating new password..." + + # Generate random password + NEW_PASSWORD=$(generate_password "$PASSWORD_LENGTH") + + if [ -z "$NEW_PASSWORD" ]; then + echo -e "${RED}Error: Failed to generate password${NC}" + exit 1 + fi + + # Hash the password + PASSWORD_HASH=$(hash_password "$NEW_PASSWORD") + + if [ $? -ne 0 ] || [ -z "$PASSWORD_HASH" ]; then + echo -e "${RED}Error: Failed to hash password${NC}" + exit 1 + fi + + # Update the hash file + update_hash_file "$PASSWORD_HASH" + + if [ $? -ne 0 ]; then + echo -e "${RED}Error: Failed to update password hash file${NC}" + exit 1 + fi + + echo "" + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN}Admin password reset successful!${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" + echo -e "${YELLOW}New Admin Credentials:${NC}" + echo -e "Username: ${GREEN}admin${NC}" + echo -e "Password: ${GREEN}$NEW_PASSWORD${NC}" + echo "" + echo -e "${YELLOW}IMPORTANT:${NC}" + echo -e "1. Save this password securely - it will not be shown again" + echo -e "2. The password hash has been saved to /secrets/admin_password_hash" + echo -e "3. Restart the Docker container for the new password to take effect" + echo "" + + exit 0 +} + +# Run main function +main diff --git a/dockerfiles/all-in-one/s6-scripts/init/script b/dockerfiles/all-in-one/s6-scripts/init/script index aef2ad213..f4204bfac 100644 --- a/dockerfiles/all-in-one/s6-scripts/init/script +++ b/dockerfiles/all-in-one/s6-scripts/init/script @@ -62,33 +62,9 @@ else log 0 "[init] ✅ JWT key already exists" fi -if [ ! -f /secrets/admin_password_hash ]; then - log 0 "[init] → Generating admin password hash..." - # Generate a random admin password if not provided - if [ -z "$ADMIN_PASSWORD" ]; then - ADMIN_PASSWORD=$(openssl rand -base64 16 | tr -d "\n") - # Only show password in verbose mode during init - if [ "$VERBOSITY" -ge 2 ]; then - echo "[init] ⚠️ Generated random admin password: $ADMIN_PASSWORD" - echo "[init] ⚠️ Please save this password securely and/or optionally change it after first login!" - fi - # Save the password temporarily for the final notification (only if newly generated) - echo "$ADMIN_PASSWORD" > /secrets/admin_password_temp - chmod 600 /secrets/admin_password_temp - fi - # Use the InstallCLI to hash the password and append generation timestamp - if [ "$VERBOSITY" -ge 2 ]; then - HASH=$(/usr/local/bin/aliasvault-cli/AliasVault.InstallCli hash-password "$ADMIN_PASSWORD") - else - HASH=$(/usr/local/bin/aliasvault-cli/AliasVault.InstallCli hash-password "$ADMIN_PASSWORD" 2>/dev/null) - fi - TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - echo "${HASH}|${TIMESTAMP}" > /secrets/admin_password_hash - chmod 600 /secrets/admin_password_hash - log 0 "[init] Admin password hash saved to /secrets/admin_password_hash" -else - log 0 "[init] ✅ Admin password hash already exists" -fi +# Admin password is not created by default +# Users must run reset-admin-password.sh to configure the admin password +log 0 "[init] ✅ Admin password configuration deferred to manual setup" # Read PostgreSQL password for database initialization POSTGRES_PASSWORD=$(cat /secrets/postgres_password) diff --git a/dockerfiles/all-in-one/s6-scripts/notification/run b/dockerfiles/all-in-one/s6-scripts/notification/run index 51ceaf18f..90f820baa 100755 --- a/dockerfiles/all-in-one/s6-scripts/notification/run +++ b/dockerfiles/all-in-one/s6-scripts/notification/run @@ -32,22 +32,21 @@ if [ "$VERBOSITY" -le 1 ]; then echo " • Admin: https://localhost:443/admin" echo "" - # Show admin credentials if available - if [ -f /secrets/admin_password_temp ]; then - ADMIN_PASSWORD=$(cat /secrets/admin_password_temp) + # Check if admin password hash file exists to determine which message to show + if [ -f /secrets/admin_password_hash ]; then + # Admin password hash exists - show the legacy warning echo "🔑 Admin Login:" echo " • Username: admin" - echo " • Password: ${ADMIN_PASSWORD}" + echo " • Password: (previously set - to reset the admin password, login to this container via \`docker exec -it [container-name] /bin/bash\` and run: reset-admin-password.sh)" echo "" - echo "⚠️ IMPORTANT: Save these credentials securely!" - echo " This password won't be shown again." - echo "" - # Clean up the temporary password file - rm -f /secrets/admin_password_temp else - echo "🔑 Admin Login:" - echo " • Username: admin" - echo " • Password: (previously set - to reset the admin password, delete the file ./secrets/admin_password_hash and restart the container)" + # No admin password hash file - show setup instructions + echo "🔑 Admin Setup:" + echo " • Admin user is not configured by default" + echo " • To configure admin access:" + echo " 1. docker exec -it [container-name] /bin/bash" + echo " 2. reset-admin-password.sh" + echo " 3. Restart the container" echo "" fi