9 Commits

Author SHA1 Message Date
f893d26791 fix: enforce LF line endings for shell scripts (.gitattributes)
All checks were successful
PR Tests / Lint, Build & Test (pull_request) Successful in 5m30s
Security Scan / Go Vulnerability Check (pull_request) Successful in 4m47s
2026-04-05 22:17:51 +02:00
68777a5516 feat: add CLI password reset command (docker exec reset-password) 2026-04-05 22:17:46 +02:00
0fcd99a191 Merge pull request 'v0.2.0-alpha' (#2) from v0.2.0-alpha into master
All checks were successful
Release Docker Image / Build & Push Docker Image (release) Successful in 5m33s
Reviewed-on: #2
2026-04-05 17:56:47 +00:00
025d23e5a6 docs: add container registry URL to deployment docs and README
All checks were successful
PR Tests / Lint, Build & Test (pull_request) Successful in 5m0s
Security Scan / Go Vulnerability Check (pull_request) Successful in 4m46s
2026-04-05 19:45:47 +02:00
be05dd5eac fix: add entrypoint.sh to fix /data permission denied on bind-mount
Some checks failed
PR Tests / Lint, Build & Test (pull_request) Has been cancelled
Security Scan / Go Vulnerability Check (pull_request) Has been cancelled
2026-04-05 19:42:18 +02:00
bb3bf0330f security: fix data loss on container restart due to relative paths
Root cause: .env.example used relative paths (./data/...) which resolve
to /app/data/ inside the container instead of the persistent volume at
/data/. This caused the database to be recreated on every container
restart, resetting the admin password to a new initial value.

Fixes:
- .env.example: comment out path settings with clear warning about
  relative paths; Dockerfile already provides correct absolute defaults
- auth: add initial_setup_complete flag in settings table as
  defence-in-depth so EnsureAdmin never re-creates an admin after
  the initial setup, even if the users table is unexpectedly empty
- main: add validateDataPaths() startup check that warns when relative
  container paths are detected (potential data-loss misconfiguration)
- auth_test: extend TestEnsureAdmin to verify the flag prevents
  admin re-creation after user deletion
2026-04-05 19:21:15 +02:00
c2d4148de6 add build to docker-compose 2026-04-05 19:12:44 +02:00
ea3e7e71ca refactor: convert force_password_change to standalone layout (no sidebar) 2026-04-05 19:03:32 +02:00
5bd77de32d Merge pull request 'v0.1.1-alpha' (#1) from v0.1.1-alpha into master
All checks were successful
Release Docker Image / Build & Push Docker Image (release) Successful in 5m11s
Reviewed-on: #1
2026-04-05 16:41:18 +00:00
14 changed files with 549 additions and 78 deletions

View File

@@ -20,10 +20,15 @@ KEYWARDEN_ENCRYPTION_KEY=change-me-encryption-key-32chars
KEYWARDEN_LOG_LEVEL=INFO
# --- Paths (optional, Docker defaults are usually fine) ---
KEYWARDEN_DB_PATH=./data/keywarden.db
KEYWARDEN_DATA_DIR=./data
KEYWARDEN_KEYS_DIR=./data/keys
KEYWARDEN_MASTER_DIR=./data/master
# IMPORTANT: These paths refer to locations INSIDE the Docker container.
# The Dockerfile already sets correct defaults (/data/...). Only override
# if you know what you are doing. Do NOT use relative paths (./data/...)
# they resolve to /app/data/ inside the container and bypass the
# persistent volume mount at /data, causing DATA LOSS on restart.
# KEYWARDEN_DB_PATH=/data/keywarden.db
# KEYWARDEN_DATA_DIR=/data
# KEYWARDEN_KEYS_DIR=/data/keys
# KEYWARDEN_MASTER_DIR=/data/master
# --- Security / Hardening (optional) ---
# Public URL used for email links and cookie config.

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
# Ensure shell scripts always have Unix line endings (LF),
# even when checked out on Windows.
*.sh text eol=lf
entrypoint.sh text eol=lf

View File

@@ -16,17 +16,17 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o keywarden -ldflags="-s -w" ./cmd/keywar
# Stage 2: Runtime
FROM alpine:3.21
RUN apk add --no-cache ca-certificates sqlite-libs tzdata curl
RUN apk add --no-cache ca-certificates sqlite-libs tzdata curl su-exec
RUN addgroup -S keywarden && adduser -S keywarden -G keywarden
WORKDIR /app
COPY --from=builder /build/keywarden .
COPY entrypoint.sh .
RUN mkdir -p /data/keys /data/master /data/avatars && \
chown -R keywarden:keywarden /data /app
USER keywarden
chown -R keywarden:keywarden /data /app && \
chmod +x /app/entrypoint.sh
ENV KEYWARDEN_PORT=8080
ENV KEYWARDEN_DB_PATH=/data/keywarden.db
@@ -42,4 +42,4 @@ VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${KEYWARDEN_PORT:-8080}/api/health || exit 1
ENTRYPOINT ["./keywarden"]
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -138,6 +138,7 @@ Join the **Keywarden Matrix chat** to discuss the project, ask questions, or sha
|---|---|
| **Primary (Gitea)** | [git.techniverse.net/scriptos/keywarden](https://git.techniverse.net/scriptos/keywarden) |
| **Mirror (GitHub)** | [github.com/pscriptos/keywarden](https://github.com/pscriptos/keywarden) |
| **Container Registry** | [git.techniverse.net/scriptos/-/packages/container/keywarden](https://git.techniverse.net/scriptos/-/packages/container/keywarden) |
The **primary repository** is hosted on Gitea. The GitHub repository is a read-only mirror.

View File

@@ -5,8 +5,11 @@
package main
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"git.techniverse.net/scriptos/keywarden/internal/audit"
"git.techniverse.net/scriptos/keywarden/internal/auth"
@@ -25,6 +28,18 @@ import (
)
func main() {
// Handle CLI subcommands before starting the server
if len(os.Args) > 1 {
switch os.Args[1] {
case "reset-password":
handleResetPassword(os.Args[2:])
return
case "help", "--help", "-h":
printUsage()
return
}
}
// Load config first (needed for log level)
cfg := config.Load()
@@ -34,6 +49,10 @@ func main() {
logging.Info("🔑 Keywarden - Centralized SSH Key Management and Deployment")
logging.Info(" https://git.techniverse.net/scriptos/keywarden")
// Validate data paths relative paths inside a container bypass the
// persistent volume mount and lead to silent data loss on restart.
validateDataPaths(cfg)
// Ensure data directories exist
for _, dir := range []string{cfg.DataDir, cfg.KeysDir, cfg.MasterDir} {
if err := os.MkdirAll(dir, 0700); err != nil {
@@ -152,3 +171,116 @@ func getEnvWithLegacy(primary, legacy, fallback string) string {
}
return fallback
}
// validateDataPaths checks for a common misconfiguration: relative paths
// (e.g. ./data/...) that resolve to the container's working directory instead
// of the persistent volume mount. This would cause silent data loss on every
// container restart.
func validateDataPaths(cfg *config.Config) {
paths := map[string]string{
"KEYWARDEN_DB_PATH": cfg.DBPath,
"KEYWARDEN_DATA_DIR": cfg.DataDir,
"KEYWARDEN_KEYS_DIR": cfg.KeysDir,
"KEYWARDEN_MASTER_DIR": cfg.MasterDir,
}
for envVar, p := range paths {
if p == "" {
continue
}
abs, err := filepath.Abs(p)
if err != nil {
continue
}
// Detect relative paths that resolve outside /data (the expected volume).
if !filepath.IsAbs(p) || (!strings.HasPrefix(abs, "/data") && !strings.HasPrefix(abs, `\data`)) {
// Only warn don't block startup for non-Docker environments.
if strings.HasPrefix(p, "./") || strings.HasPrefix(p, "../") || (!filepath.IsAbs(p) && p != "") {
logging.Warn("⚠ %s is a relative path (%s → %s). Inside a Docker container this may bypass the persistent volume and cause DATA LOSS on restart. Use an absolute path like /data/... instead.", envVar, p, abs)
}
}
}
}
// handleResetPassword implements the "reset-password" CLI subcommand.
// Usage: keywarden reset-password --username <name> [--reset-mfa]
func handleResetPassword(args []string) {
var username string
var resetMFA bool
for i := 0; i < len(args); i++ {
switch args[i] {
case "--username", "-u":
if i+1 < len(args) {
i++
username = args[i]
} else {
fmt.Fprintln(os.Stderr, "Error: --username requires a value")
os.Exit(1)
}
case "--reset-mfa":
resetMFA = true
default:
fmt.Fprintf(os.Stderr, "Error: unknown flag '%s'\n", args[i])
fmt.Fprintln(os.Stderr, "Usage: keywarden reset-password --username <name> [--reset-mfa]")
os.Exit(1)
}
}
if username == "" {
fmt.Fprintln(os.Stderr, "Error: --username is required")
fmt.Fprintln(os.Stderr, "Usage: keywarden reset-password --username <name> [--reset-mfa]")
os.Exit(1)
}
// Load config for DB path
cfg := config.Load()
// Open database
db, err := database.New(cfg.DBPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
os.Exit(1)
}
defer db.Close()
authSvc := auth.NewService(db)
// Look up the user
user, err := authSvc.GetUserByUsername(username)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: user '%s' not found\n", username)
os.Exit(1)
}
// Reset password
newPassword, err := authSvc.ResetPassword(user.ID, resetMFA)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to reset password: %v\n", err)
os.Exit(1)
}
fmt.Println("════════════════════════════════════════════════════════════")
fmt.Printf(" Password reset successful for user: %s\n", user.Username)
fmt.Printf(" New password: %s\n", newPassword)
if resetMFA {
fmt.Println(" MFA has been disabled for this account.")
}
fmt.Println(" The user must change this password after login.")
fmt.Println("════════════════════════════════════════════════════════════")
}
// printUsage displays available CLI subcommands
func printUsage() {
fmt.Println("Keywarden - Centralized SSH Key Management and Deployment")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" keywarden Start the server")
fmt.Println(" keywarden reset-password --username <name> Reset a user's password")
fmt.Println(" --reset-mfa Also disable MFA")
fmt.Println(" keywarden help Show this help")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" docker exec -it keywarden ./keywarden reset-password --username admin")
fmt.Println(" docker exec -it keywarden ./keywarden reset-password --username admin --reset-mfa")
}

View File

@@ -1,6 +1,7 @@
services:
keywarden:
image: git.techniverse.net/scriptos/keywarden:latest
build: .
container_name: keywarden
restart: unless-stopped
ports:

View File

@@ -246,3 +246,29 @@ Send a test email to verify SMTP configuration.
### Backup & Restore
See [Backup & Restore](backup-restore.md) for details.
## CLI Commands
Keywarden provides CLI commands for administrative tasks that can be run via `docker exec`.
### Password Reset
Reset a user's password when they are locked out or have forgotten it:
```bash
docker exec -it keywarden ./keywarden reset-password --username <name>
```
This generates a new random password, prints it to the terminal, and forces the user to change it on next login. The account lockout counter is also cleared.
To additionally disable MFA (e.g. when the user lost their TOTP device):
```bash
docker exec -it keywarden ./keywarden reset-password --username <name> --reset-mfa
```
### Help
```bash
docker exec -it keywarden ./keywarden help
```

View File

@@ -8,7 +8,23 @@ Keywarden is designed as a single-container application with an embedded SQLite
### Docker Image
Build from source or use the pre-built image:
Keywarden provides pre-built Docker images via the container registry:
**Container Registry:** [git.techniverse.net/scriptos/-/packages/container/keywarden](https://git.techniverse.net/scriptos/-/packages/container/keywarden)
Pull the latest image:
```bash
docker pull git.techniverse.net/scriptos/keywarden:latest
```
Or pull a specific version:
```bash
docker pull git.techniverse.net/scriptos/keywarden:v0.1.1-alpha
```
Alternatively, build from source:
```bash
# Build from source

View File

@@ -27,12 +27,13 @@ Common issues and solutions for Keywarden.
**Solutions**:
- Check the very first startup logs: `docker compose logs keywarden`
- If you missed the password, delete the database and restart to trigger a fresh setup:
- Reset the password via CLI command (no restart needed):
```bash
docker compose down
docker volume rm keywarden_keywarden_data
docker compose up -d
docker compose logs keywarden
docker exec -it keywarden ./keywarden reset-password --username admin
```
- If MFA is also lost, add `--reset-mfa`:
```bash
docker exec -it keywarden ./keywarden reset-password --username admin --reset-mfa
```
## Login Issues
@@ -50,7 +51,10 @@ Common issues and solutions for Keywarden.
**Solutions**:
- Wait for the lockout period to expire (default: 15 minutes)
- Ask an administrator to unlock the account from the user management page
- If you're the only owner: wait for the lockout to expire, or delete and recreate the database
- If you're the only owner: reset your password via CLI (this also clears lockout):
```bash
docker exec -it keywarden ./keywarden reset-password --username admin
```
### MFA Code Invalid

15
entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# Keywarden Docker Entrypoint
# Ensures data directories exist with correct ownership before
# dropping privileges to the keywarden user.
set -e
# Create data directories (bind-mount from host may be owned by root)
mkdir -p /data/keys /data/master /data/avatars
# Fix ownership so the unprivileged keywarden user can write
chown -R keywarden:keywarden /data
# Drop privileges and exec the application
exec su-exec keywarden ./keywarden "$@"

View File

@@ -104,6 +104,59 @@ func (s *Service) Login(username, password string) (*models.User, error) {
return user, nil
}
// GetUserByUsername returns a user by their username
func (s *Service) GetUserByUsername(username string) (*models.User, error) {
user := &models.User{}
err := s.db.QueryRow(
`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, avatar_base64, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users WHERE username = ?`,
username,
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.AvatarBase64, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to query user: %w", err)
}
return user, nil
}
// ResetPassword generates a new random password for the given user, sets
// must_change_password = true, resets lockout counters and optionally
// disables MFA. Returns the generated password.
func (s *Service) ResetPassword(userID int64, resetMFA bool) (string, error) {
password, err := generateSecurePassword(20)
if err != nil {
return "", fmt.Errorf("failed to generate password: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
}
_, err = s.db.Exec(
`UPDATE users SET password_hash = ?, must_change_password = 1, failed_login_attempts = 0, locked_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
string(hash), userID,
)
if err != nil {
return "", fmt.Errorf("failed to update password: %w", err)
}
if resetMFA {
_, err = s.db.Exec(
`UPDATE users SET mfa_enabled = 0, mfa_secret = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
userID,
)
if err != nil {
return "", fmt.Errorf("failed to reset MFA: %w", err)
}
}
return password, nil
}
// GetUserByID returns a user by their ID
func (s *Service) GetUserByID(id int64) (*models.User, error) {
user := &models.User{}
@@ -156,12 +209,23 @@ func (s *Service) HasUsers() (bool, error) {
// EnsureAdmin creates a default owner user if no users exist.
// It auto-generates a secure password and returns (created, generatedPassword, error).
// A persistent flag ("initial_setup_complete") is stored in the settings table
// so that an admin account is never re-created after the initial setup, even
// if the users table is unexpectedly empty (e.g. due to a misconfigured volume).
func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
// Defence-in-depth: if the initial setup was already completed once,
// never auto-create another admin even when the users table is empty.
if s.isInitialSetupComplete() {
return false, "", nil
}
hasUsers, err := s.HasUsers()
if err != nil {
return false, "", err
}
if hasUsers {
// Users exist but no flag yet (upgrade path) set the flag now.
s.markInitialSetupComplete()
return false, "", nil
}
@@ -183,9 +247,28 @@ func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
if err != nil {
return false, "", err
}
// Mark initial setup as complete so the password is never regenerated.
s.markInitialSetupComplete()
return true, password, nil
}
// isInitialSetupComplete checks whether the initial admin setup has already
// been performed by looking for a flag in the settings table.
func (s *Service) isInitialSetupComplete() bool {
var val string
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'initial_setup_complete'`).Scan(&val)
return err == nil && val == "true"
}
// markInitialSetupComplete persists the initial-setup flag in the settings table.
func (s *Service) markInitialSetupComplete() {
s.db.Exec(
`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('initial_setup_complete', 'true', CURRENT_TIMESTAMP)`,
)
}
// generateSecurePassword creates a cryptographically secure random password
func generateSecurePassword(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

View File

@@ -236,7 +236,7 @@ func TestEnsureAdmin(t *testing.T) {
t.Fatalf("Expected owner role, got %q", user.Role)
}
// Second call should be no-op
// Second call should be no-op (users exist)
created2, _, err := svc.EnsureAdmin("admin2", "admin2@test.com")
if err != nil {
t.Fatalf("Second EnsureAdmin should not fail: %v", err)
@@ -250,6 +250,25 @@ func TestEnsureAdmin(t *testing.T) {
if err != ErrInvalidCredentials {
t.Fatalf("admin2 should not have been created")
}
// initial_setup_complete flag should be set
if !svc.isInitialSetupComplete() {
t.Fatal("Expected initial_setup_complete flag to be set after EnsureAdmin")
}
// Even if all users are deleted, EnsureAdmin must NOT create a new admin
// because the initial_setup_complete flag is set (defence-in-depth).
_, err = db.Exec(`DELETE FROM users`)
if err != nil {
t.Fatalf("Failed to delete all users: %v", err)
}
created3, _, err := svc.EnsureAdmin("admin3", "admin3@test.com")
if err != nil {
t.Fatalf("Third EnsureAdmin should not fail: %v", err)
}
if created3 {
t.Fatal("EnsureAdmin must not create a user when initial_setup_complete flag is set")
}
}
func TestGetAllUsers(t *testing.T) {
@@ -399,3 +418,100 @@ func TestEnableDisableMFA(t *testing.T) {
t.Fatal("MFA should be disabled")
}
}
func TestGetUserByUsername(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
user, err := svc.GetUserByUsername("testuser")
if err != nil {
t.Fatalf("GetUserByUsername failed: %v", err)
}
if user.ID != created.ID {
t.Fatalf("Expected user ID %d, got %d", created.ID, user.ID)
}
if user.Username != "testuser" {
t.Fatalf("Expected username 'testuser', got %q", user.Username)
}
// Non-existent user
_, err = svc.GetUserByUsername("nonexistent")
if err != ErrUserNotFound {
t.Fatalf("Expected ErrUserNotFound, got %v", err)
}
}
func TestResetPassword(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "oldpass", "user", false)
// Reset without MFA reset
newPass, err := svc.ResetPassword(created.ID, false)
if err != nil {
t.Fatalf("ResetPassword failed: %v", err)
}
if len(newPass) != 20 {
t.Fatalf("Expected 20-char password, got %d chars", len(newPass))
}
// Old password should fail
_, err = svc.Login("testuser", "oldpass")
if err != ErrInvalidCredentials {
t.Fatal("Old password should no longer work after reset")
}
// New password should work
user, err := svc.Login("testuser", newPass)
if err != nil {
t.Fatalf("Login with reset password failed: %v", err)
}
if !user.MustChangePassword {
t.Fatal("must_change_password should be set after reset")
}
// Account lockout should be cleared
if user.FailedLoginAttempts != 0 {
t.Fatalf("Expected 0 failed attempts after reset, got %d", user.FailedLoginAttempts)
}
}
func TestResetPasswordWithMFA(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
svc := NewService(db)
created, _ := svc.Register("testuser", "test@example.com", "oldpass", "user", false)
// Enable MFA
svc.EnableMFA(created.ID, "TESTSECRET")
// Reset with MFA reset
newPass, err := svc.ResetPassword(created.ID, true)
if err != nil {
t.Fatalf("ResetPassword with MFA reset failed: %v", err)
}
// Verify MFA is disabled
user, err := svc.GetUserByID(created.ID)
if err != nil {
t.Fatalf("GetUserByID failed: %v", err)
}
if user.MFAEnabled {
t.Fatal("MFA should be disabled after reset with --reset-mfa")
}
if user.MFASecret != "" {
t.Fatalf("MFA secret should be empty after reset, got %q", user.MFASecret)
}
// New password should work
_, err = svc.Login("testuser", newPass)
if err != nil {
t.Fatalf("Login with reset password failed: %v", err)
}
}

View File

@@ -306,7 +306,6 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
"admin_settings", "system_info",
"cron", "cron_add", "cron_edit",
"assignments", "assignments_add", "assignments_edit",
"force_password_change",
}
for _, page := range pages {
pageContent, err := fs.ReadFile(templateFS, "templates/"+page+".html")
@@ -335,6 +334,17 @@ func (h *Handler) loadTemplates(templateFS embed.FS) {
}
h.templates["login"] = loginTmpl
// Force password change page has its own layout (standalone, no sidebar)
fpcContent, err := fs.ReadFile(templateFS, "templates/force_password_change.html")
if err != nil {
logging.Fatal("Failed to read force_password_change template: %v", err)
}
fpcTmpl, err := template.New("force_password_change").Funcs(funcMap).Parse(string(fpcContent))
if err != nil {
logging.Fatal("Failed to parse force_password_change: %v", err)
}
h.templates["force_password_change"] = fpcTmpl
// MFA required page has its own layout (standalone, no sidebar)
mfaReqContent, err := fs.ReadFile(templateFS, "templates/mfa_required.html")
if err != nil {
@@ -2225,7 +2235,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
User: user,
PasswordPolicy: &policy,
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}
@@ -2239,7 +2249,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
PasswordPolicy: &policy,
Flash: &Flash{Type: "danger", Message: "Passwords do not match."},
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}
@@ -2250,7 +2260,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
PasswordPolicy: &policy,
Flash: &Flash{Type: "danger", Message: err.Error()},
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}
@@ -2261,7 +2271,7 @@ func (h *Handler) handleForcePasswordChange(w http.ResponseWriter, r *http.Reque
PasswordPolicy: &policy,
Flash: &Flash{Type: "danger", Message: "Failed to change password: " + err.Error()},
}
h.templates["force_password_change"].ExecuteTemplate(w, "base", data)
h.templates["force_password_change"].Execute(w, data)
return
}

View File

@@ -1,65 +1,123 @@
{{define "content"}}
<div class="row row-deck row-cards justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="ti ti-lock-exclamation"></i> Password Change Required</h3>
</div>
<div class="card-body">
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">You must change your password</h4>
<div class="text-secondary">Your administrator has set an initial password for your account. Please choose a new personal password to continue.</div>
</div>
</div>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<title>Password Change Required - {{appName}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
<script>
(function() {
var resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
document.documentElement.setAttribute('data-bs-theme', resolved);
document.documentElement.style.colorScheme = resolved;
})();
</script>
<style>
html[data-bs-theme="dark"],
html[data-bs-theme="dark"] body { background-color: #1a2234; color-scheme: dark; }
html[data-bs-theme="light"],
html[data-bs-theme="light"] body { background-color: #f1f5f9; color-scheme: light; }
[data-bs-theme="dark"] ::selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="dark"] ::-moz-selection { background: #3d6098; color: #f0f4f8; }
[data-bs-theme="light"] ::selection { background: #b3d4fc; color: #1a1a1a; }
[data-bs-theme="light"] ::-moz-selection { background: #b3d4fc; color: #1a1a1a; }
</style>
<link rel="stylesheet" href="/static/css/tabler.min.css">
<link rel="stylesheet" href="/static/css/tabler-icons.min.css">
</head>
<body class="d-flex flex-column">
<div class="page page-center">
<div class="container container-tight py-4">
<div class="text-center mb-4">
<h1><i class="ti ti-key"></i> {{appName}}</h1>
<p class="text-secondary">Centralized SSH Key Management and Deployment</p>
</div>
<div class="card card-md">
<div class="card-body">
<h2 class="h2 text-center mb-4">
<i class="ti ti-lock-exclamation"></i> Password Change Required
</h2>
{{if .PasswordPolicy}}
<div class="alert alert-info">
<div class="d-flex">
<div><i class="ti ti-info-circle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">Password Requirements</h4>
<ul class="mb-0">
<li>Minimum <strong>{{.PasswordPolicy.MinLength}}</strong> characters</li>
{{if .PasswordPolicy.RequireUpper}}<li>At least one <strong>uppercase letter</strong> (A-Z)</li>{{end}}
{{if .PasswordPolicy.RequireLower}}<li>At least one <strong>lowercase letter</strong> (a-z)</li>{{end}}
{{if .PasswordPolicy.RequireDigit}}<li>At least one <strong>digit</strong> (0-9)</li>{{end}}
{{if .PasswordPolicy.RequireSpecial}}<li>At least one <strong>special character</strong> (!@#$...)</li>{{end}}
</ul>
<div class="alert alert-warning">
<div class="d-flex">
<div><i class="ti ti-alert-triangle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">You must change your password</h4>
<div class="text-secondary">Your administrator has set an initial password for your account. Please choose a new personal password to continue.</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
<form action="/password/change" method="post" autocomplete="off">
<div class="mb-3">
<label class="form-label required">New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
<input type="password" name="new_password" class="form-control" placeholder="New password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
{{if .Flash}}
<div class="alert alert-{{.Flash.Type}}">
<i class="ti ti-alert-circle"></i> {{.Flash.Message}}
</div>
{{end}}
{{if .PasswordPolicy}}
<div class="alert alert-info">
<div class="d-flex">
<div><i class="ti ti-info-circle icon alert-icon"></i></div>
<div>
<h4 class="alert-title">Password Requirements</h4>
<ul class="mb-0">
<li>Minimum <strong>{{.PasswordPolicy.MinLength}}</strong> characters</li>
{{if .PasswordPolicy.RequireUpper}}<li>At least one <strong>uppercase letter</strong> (A-Z)</li>{{end}}
{{if .PasswordPolicy.RequireLower}}<li>At least one <strong>lowercase letter</strong> (a-z)</li>{{end}}
{{if .PasswordPolicy.RequireDigit}}<li>At least one <strong>digit</strong> (0-9)</li>{{end}}
{{if .PasswordPolicy.RequireSpecial}}<li>At least one <strong>special character</strong> (!@#$...)</li>{{end}}
</ul>
</div>
</div>
</div>
{{end}}
<form action="/password/change" method="post" autocomplete="off">
<div class="mb-3">
<label class="form-label required">New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock"></i></span>
<input type="password" name="new_password" class="form-control" placeholder="New password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
</div>
<div class="mb-3">
<label class="form-label required">Confirm New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock-check"></i></span>
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm new password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-lock-check"></i> Set New Password
</button>
</div>
</form>
<div class="text-center mt-3">
<a href="/logout" class="text-secondary"><i class="ti ti-logout"></i> Logout</a>
</div>
</div>
<div class="mb-3">
<label class="form-label required">Confirm New Password</label>
<div class="input-icon">
<span class="input-icon-addon"><i class="ti ti-lock-check"></i></span>
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm new password" required minlength="{{if .PasswordPolicy}}{{.PasswordPolicy.MinLength}}{{else}}8{{end}}">
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
<i class="ti ti-lock-check"></i> Set New Password
</button>
</div>
</form>
<div class="text-center mt-3">
<a href="/logout" class="text-secondary"><i class="ti ti-logout"></i> Logout</a>
</div>
</div>
</div>
</div>
</div>
{{end}}
<!-- CSRF Protection -->
<script>
(function() {
var m = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/);
var token = m ? decodeURIComponent(m[1]) : '';
document.querySelectorAll('form').forEach(function(form) {
if ((form.method || 'get').toLowerCase() === 'post' && !form.querySelector('input[name="_csrf"]')) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = token;
form.prepend(input);
}
});
})();
</script>
</body>
</html>