Compare commits
9 Commits
v0.1.1-alp
...
v0.2.1-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| f893d26791 | |||
| 68777a5516 | |||
| 0fcd99a191 | |||
| 025d23e5a6 | |||
| be05dd5eac | |||
| bb3bf0330f | |||
| c2d4148de6 | |||
| ea3e7e71ca | |||
| 5bd77de32d |
13
.env.example
13
.env.example
@@ -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
4
.gitattributes
vendored
Normal 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
|
||||
10
Dockerfile
10
Dockerfile
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
keywarden:
|
||||
image: git.techniverse.net/scriptos/keywarden:latest
|
||||
build: .
|
||||
container_name: keywarden
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
15
entrypoint.sh
Normal 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 "$@"
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user