From 764ee2b9af3b8acc8d9a85b944f1a6fe18f60c39 Mon Sep 17 00:00:00 2001 From: Manas Maiti Date: Mon, 23 Mar 2026 11:33:58 +0100 Subject: [PATCH] feat(auth): single password field with plaintext/bcrypt detection --- backend/config/config.go | 75 ++++++++++++++++++---------- backend/handlers/auth.go | 10 ++-- backend/handlers/config.go | 17 +++---- backend/main.go | 6 +-- config.example.yaml | 2 +- docs/authentication/index.md | 29 +++++++---- docs/authentication/setup.md | 6 +-- docs/configuration/config-file.md | 4 +- docs/features/simple-admin.md | 2 +- frontend/src/components/Settings.tsx | 34 +++++++++---- 10 files changed, 113 insertions(+), 72 deletions(-) diff --git a/backend/config/config.go b/backend/config/config.go index c4c3bdc..f847841 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -76,15 +76,19 @@ type UsersConfig struct { } type LocalUser struct { - Username string `yaml:"username" json:"username"` - PasswordHash string `yaml:"password_hash" json:"password_hash"` - IsAdmin bool `yaml:"is_admin" json:"is_admin"` + Username string `yaml:"username" json:"username"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` // plaintext or bcrypt; hashed on first run if plaintext + IsAdmin bool `yaml:"is_admin" json:"is_admin"` } type DefaultAdminUser struct { - Username string `yaml:"username" json:"username"` - Password string `yaml:"password,omitempty" json:"password,omitempty"` // plaintext, used only on first bootstrap - PasswordHash string `yaml:"password_hash,omitempty" json:"password_hash,omitempty"` // bcrypt hash, stored after bootstrap + Username string `yaml:"username" json:"username"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` // plaintext or bcrypt; hashed on first run if plaintext +} + +// isBcryptHash returns true if s looks like a bcrypt hash ($2a$, $2b$, $2y$). +func isBcryptHash(s string) bool { + return len(s) >= 60 && (strings.HasPrefix(s, "$2a$") || strings.HasPrefix(s, "$2b$") || strings.HasPrefix(s, "$2y$")) } // sources tracks where each config key got its value. @@ -290,35 +294,54 @@ func Load(configPath string) (*Config, error) { return c, nil } -// BootstrapDefaultAdmin hashes the default admin password and stores it in default_admin. -// Does not add to local_users or clear default_admin. Call after Load. -func BootstrapDefaultAdmin(c *Config) (bool, error) { - if c.ConfigPath == "" || c.Users.DefaultAdmin == nil { - return false, nil +// BootstrapUsers hashes plaintext passwords for default_admin and local_users, then writes the config. +// If password looks like plaintext (not bcrypt), it is hashed and replaced in-place. Call after Load. +func BootstrapUsers(c *Config) error { + if c.ConfigPath == "" { + return nil } - da := c.Users.DefaultAdmin - if da.Username == "" || da.Password == "" { - return false, nil + modified := false + + // Default admin + if c.Users.DefaultAdmin != nil { + da := c.Users.DefaultAdmin + if da.Username != "" && da.Password != "" && !isBcryptHash(da.Password) { + hash, err := bcrypt.GenerateFromPassword([]byte(da.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash default admin password: %w", err) + } + da.Password = string(hash) + modified = true + log.Printf("[config] bootstrapped default admin %q (password hashed)", da.Username) + } } - // Already bootstrapped (hash present) - if da.PasswordHash != "" { - return false, nil + + // Local users + for i := range c.Users.LocalUsers { + u := &c.Users.LocalUsers[i] + if u.Password == "" || isBcryptHash(u.Password) { + continue + } + hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash password for local user %q: %w", u.Username, err) + } + u.Password = string(hash) + modified = true + log.Printf("[config] bootstrapped local user %q (password hashed)", u.Username) } - hash, err := bcrypt.GenerateFromPassword([]byte(da.Password), bcrypt.DefaultCost) - if err != nil { - return false, fmt.Errorf("hash default admin password: %w", err) + + if !modified { + return nil } - da.PasswordHash = string(hash) - da.Password = "" data, err := marshalConfigForPath(c) if err != nil { - return false, err + return err } if err := os.WriteFile(c.ConfigPath, data, 0600); err != nil { - return false, fmt.Errorf("write config after bootstrap: %w", err) + return fmt.Errorf("write config after bootstrap: %w", err) } - log.Printf("[config] bootstrapped default admin %q (password hashed, stored in default_admin)", da.Username) - return true, nil + return nil } func marshalConfigForPath(c *Config) ([]byte, error) { diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go index 4f20ae3..d520483 100644 --- a/backend/handlers/auth.go +++ b/backend/handlers/auth.go @@ -92,7 +92,7 @@ func NewAuthHandler(cfg *config.Config) *AuthHandler { localUsers = []config.LocalUser{} } oauthProviders := buildOAuthProviders(cfg) - hasLocalAuth := cfg.Auth.LocalAuthEnabled && (len(localUsers) > 0 || (cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.PasswordHash != "")) + hasLocalAuth := cfg.Auth.LocalAuthEnabled && (len(localUsers) > 0 || (cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.Password != "")) return &AuthHandler{ oauth2Configs: oauth2Configs, jwtSecret: []byte(cfg.Auth.JWTSecret), @@ -164,7 +164,7 @@ func (h *AuthHandler) LocalLogin(c *gin.Context) { // Check local_users first for i := range h.localUsers { if strings.EqualFold(h.localUsers[i].Username, username) { - if err := bcrypt.CompareHashAndPassword([]byte(h.localUsers[i].PasswordHash), []byte(password)); err != nil { + if err := bcrypt.CompareHashAndPassword([]byte(h.localUsers[i].Password), []byte(password)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } @@ -173,9 +173,9 @@ func (h *AuthHandler) LocalLogin(c *gin.Context) { } } // Check default_admin if not found in local_users - if authUsername == "" && h.defaultAdmin != nil && h.defaultAdmin.PasswordHash != "" && + if authUsername == "" && h.defaultAdmin != nil && h.defaultAdmin.Password != "" && strings.EqualFold(h.defaultAdmin.Username, username) { - if err := bcrypt.CompareHashAndPassword([]byte(h.defaultAdmin.PasswordHash), []byte(password)); err != nil { + if err := bcrypt.CompareHashAndPassword([]byte(h.defaultAdmin.Password), []byte(password)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } @@ -430,7 +430,7 @@ func (h *AuthHandler) Me(c *gin.Context) { } } } - if !isAdmin && h.defaultAdmin != nil && h.defaultAdmin.PasswordHash != "" && + if !isAdmin && h.defaultAdmin != nil && h.defaultAdmin.Password != "" && strings.EqualFold(h.defaultAdmin.Username, emailStr) { isAdmin = true } diff --git a/backend/handlers/config.go b/backend/handlers/config.go index f0f60fc..41937e7 100644 --- a/backend/handlers/config.go +++ b/backend/handlers/config.go @@ -136,13 +136,13 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) { } } - // Build local_users for UI (no password_hash) + // Build local_users for UI (password not exposed) localUsersUI := make([]map[string]interface{}, 0, len(cfg.Users.LocalUsers)) for _, u := range cfg.Users.LocalUsers { localUsersUI = append(localUsersUI, map[string]interface{}{ "username": u.Username, "is_admin": u.IsAdmin, - "password_set": u.PasswordHash != "", + "password_set": u.Password != "", }) } @@ -152,7 +152,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) { defaultAdminPasswordSet := false if cfg.Users.DefaultAdmin != nil { defaultAdminUsername = cfg.Users.DefaultAdmin.Username - defaultAdminPasswordSet = cfg.Users.DefaultAdmin.Password != "" || cfg.Users.DefaultAdmin.PasswordHash != "" + defaultAdminPasswordSet = cfg.Users.DefaultAdmin.Password != "" } values := ConfigValuesResponse{ @@ -295,7 +295,6 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { da := &config.DefaultAdminUser{Username: v} if prev != nil { da.Password = prev.Password - da.PasswordHash = prev.PasswordHash } cfg.Users.DefaultAdmin = da } else { @@ -310,8 +309,8 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { // Keep existing credentials on hash failure } else { cfg.Users.DefaultAdmin = &config.DefaultAdminUser{ - Username: cfg.Users.DefaultAdmin.Username, - PasswordHash: string(hash), + Username: cfg.Users.DefaultAdmin.Username, + Password: string(hash), } } } @@ -369,9 +368,9 @@ func toLocalUsers(v interface{}, existing []config.LocalUser) ([]config.LocalUse if !ok { return nil, false } - existingByUser := make(map[string]string) // username -> password_hash + existingByUser := make(map[string]string) // username -> stored password (hash) for _, u := range existing { - existingByUser[u.Username] = u.PasswordHash + existingByUser[u.Username] = u.Password } out := make([]config.LocalUser, 0, len(arr)) for _, a := range arr { @@ -406,7 +405,7 @@ func toLocalUsers(v interface{}, existing []config.LocalUser) ([]config.LocalUse } } } - out = append(out, config.LocalUser{Username: username, PasswordHash: hash, IsAdmin: isAdmin}) + out = append(out, config.LocalUser{Username: username, Password: hash, IsAdmin: isAdmin}) } return out, true } diff --git a/backend/main.go b/backend/main.go index f74604d..4723768 100644 --- a/backend/main.go +++ b/backend/main.go @@ -16,8 +16,8 @@ func main() { if err != nil { log.Fatalf("config: %v", err) } - if _, err := config.BootstrapDefaultAdmin(cfg); err != nil { - log.Fatalf("config bootstrap default admin: %v", err) + if err := config.BootstrapUsers(cfg); err != nil { + log.Fatalf("config bootstrap: %v", err) } if err := os.MkdirAll(cfg.Server.RootPath, 0750); err != nil { @@ -58,7 +58,7 @@ func main() { localAdminUsernames = append(localAdminUsernames, u.Username) } } - if cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.PasswordHash != "" { + if cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.Password != "" { localAdminUsernames = append(localAdminUsernames, cfg.Users.DefaultAdmin.Username) } configGroup := api.Group("/config", middleware.Auth(), middleware.RequireAdmin(cfg.Users.AdminEmails, localAdminUsernames)) diff --git a/config.example.yaml b/config.example.yaml index 1c118b7..15f426b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -31,4 +31,4 @@ users: # default_admin: bootstrap on startup (password hashed, stored in default_admin; not added to local_users) # default_admin: # username: admin - # password: changeme # replaced by password_hash after first run + # password: changeme # plaintext hashed on first run and replaced in-place diff --git a/docs/authentication/index.md b/docs/authentication/index.md index bac0fa7..a2412ba 100644 --- a/docs/authentication/index.md +++ b/docs/authentication/index.md @@ -4,7 +4,7 @@ icon: material/shield-account features: - title: Local users icon: fontawesome/regular/user - description: Local with username and password stored in the config file in bcrypt hash format. + description: Local username and password. Use `password` (plaintext or bcrypt; plaintext hashed on first run). - title: Social Authentication icon: material/login description: Use Google or Github as OAuth providers. @@ -19,6 +19,8 @@ Filetree supports **Google OAuth**, **GitHub OAuth**, and **local username/passw Local auth uses username and password stored in the config file. No OAuth or external provider is required. Filetree stores the password in a bcrypt hash format. On success, a JWT is issued and the frontend stores it for subsequent requests. +Both `default_admin` and `local_users` use a single `password` field. Use plaintext (e.g. `changeme`) — on first startup, Filetree hashes it and replaces it in-place. You can also provide a bcrypt hash (e.g. from `htpasswd -nbB user pass`). + === "YAML" ``` yaml title="config.yaml" auth: @@ -31,9 +33,9 @@ Local auth uses username and password stored in the config file. No OAuth or ext username: admin password: changeme local_users: - username: heapoftrash - password_hash: $2a$10$... - is_admin: false + - username: heapoftrash + password: changeme + is_admin: false ``` === "JSON" @@ -49,11 +51,13 @@ Local auth uses username and password stored in the config file. No OAuth or ext "username": "admin", "password": "changeme" }, - "local_users": { - "username": "heapoftrash", - "password_hash": "$2a$10$...", - "is_admin": false - } + "local_users": [ + { + "username": "heapoftrash", + "password": "changeme", + "is_admin": false + } + ] } } ``` @@ -162,7 +166,7 @@ An example config of all authentication methods admin_emails: [admin@example.com] local_users: - username: bob - password_hash: $2a$10$... # set via Admin UI or default_admin + password: changeme # plaintext hashed on first run, or use bcrypt hash is_admin: false default_admin: username: admin @@ -195,7 +199,7 @@ An example config of all authentication methods "local_users": [ { "username": "bob", - "password_hash": "$2a$10$...", + "password": "changeme", "is_admin": false } ], @@ -207,6 +211,9 @@ An example config of all authentication methods } ``` +!!! note "Password bootstrap" + `default_admin` and `local_users` use a single `password` field. Plaintext is hashed on first startup and replaced in-place. You can also provide a bcrypt hash directly. + !!! note "OAuth admins" `admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, set `is_admin: true` per user in `local_users`. diff --git a/docs/authentication/setup.md b/docs/authentication/setup.md index 93be0a6..840da4b 100644 --- a/docs/authentication/setup.md +++ b/docs/authentication/setup.md @@ -22,8 +22,8 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. users: default_admin: username: admin - password: changeme # hashed on first run; add more users via Settings - local_users: [] # or add {username, password_hash, is_admin} here + password: changeme # hashed on first run + local_users: [] # or add {username, password, is_admin} — password hashed on first run ``` === "Environment variables" @@ -99,7 +99,7 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. users: local_users: - username: admin - password_hash: $2a$10$... + password: $2a$10$... is_admin: true ``` diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index a33b0ba..3f425cc 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -53,8 +53,8 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j | Key | Type | Description | |-----|------|-------------| | `admin_emails` | []string | OAuth user emails that get admin role | -| `local_users` | []object | `{username, password_hash, is_admin}` | -| `default_admin` | object | `{username, password}` for bootstrap when no users exist | +| `local_users` | []object | `{username, password, is_admin}` — `password` accepts plaintext (hashed on first run) or bcrypt hash | +| `default_admin` | object | `{username, password}` — `password` accepts plaintext (hashed on first run) or bcrypt hash | ## Example diff --git a/docs/features/simple-admin.md b/docs/features/simple-admin.md index 46dacfd..e4394bb 100644 --- a/docs/features/simple-admin.md +++ b/docs/features/simple-admin.md @@ -26,4 +26,4 @@ Changes are written back to the config file. No database migrations or extra set ## Default admin bootstrap -When no users exist, you can define a `default_admin` in config with `username` and `password`. On first successful login, the password is hashed and stored in `default_admin.password_hash`. The user is not added to `local_users`; change the password via the admin UI or config to persist. +When no users exist, you can define a `default_admin` in config with `username` and `password`. On first startup, plaintext password is hashed and replaced in-place. The user is not added to `local_users`; change the password via the admin UI or config to persist. diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 0c79d07..21cae70 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -20,6 +20,7 @@ import { import { CloudServerOutlined, DeleteOutlined, + InfoCircleOutlined, SafetyOutlined, GoogleOutlined, GithubOutlined, @@ -47,6 +48,17 @@ const SECTION_ICONS: Record = { users: , } +const ExtraWithIcon = ({ children }: { children: React.ReactNode }) => { + const { token } = theme.useToken() + if (!children) return null + return ( + + + {children} + + ) +} + const TOP_LEVEL_SECTIONS = [ { id: 'server', label: 'Server' }, { id: 'auth_providers', label: 'Auth Providers' }, @@ -341,7 +353,7 @@ export default function Settings() { style={{ marginBottom: 16 }} /> - Changes may require a server restart to take effect. + Changes may require a server restart to take effect.
{clientSecretSet ? 'Value is set. Cannot be changed after save.' : 'Enter client secret to save.'}} style={{ marginBottom: 12 }} > + {isSet ? 'Value is set.' : 'Not set.'}} style={{ marginBottom: 12 }}> ) @@ -549,7 +561,7 @@ const ProviderField = React.memo(function ProviderField({ {field.extra} : undefined} style={{ marginBottom: 12 }} > @@ -581,7 +593,7 @@ const ConfigField = React.memo(function ConfigField({ if (field.secret && !field.editable) { const isSet = !!rawValue return ( - + {isSet ? 'Value is set.' : 'Not set.'}} style={{ marginBottom: 12 }}> ) @@ -593,7 +605,7 @@ const ConfigField = React.memo(function ConfigField({ {isSet ? 'Value is set. Enter a new value to change it.' : 'Enter password for first-time setup.'}} style={{ marginBottom: 12 }} > @@ -622,7 +634,7 @@ const ConfigField = React.memo(function ConfigField({ Enable an OAuth provider (Google or GitHub) to add admin emails. : undefined} style={{ marginBottom: 12 }} > @@ -688,7 +700,7 @@ const ConfigField = React.memo(function ConfigField({ return ( Enable "Local users enabled" above to add local users. : undefined} > {(fields, { add, remove }) => ( @@ -708,7 +720,7 @@ const ConfigField = React.memo(function ConfigField({ )} {fields.map(({ key, name, ...restField }) => { - const item = localUsersFormValues[name] + const item = list[name] ?? localUsersFormValues?.[name] ?? {} const passwordSet = !!item?.password_set return ( @@ -725,7 +737,7 @@ const ConfigField = React.memo(function ConfigField({ name={[name, 'password']} label="Password" rules={!passwordSet ? [{ required: true, message: 'Password is required for new users' }] : undefined} - extra={passwordSet ? 'Value is set. Enter a new value to change it.' : undefined} + extra={passwordSet ? Value is set. Enter a new value to change it. : undefined} style={{ marginBottom: 0 }} > {field.extra} : undefined} style={{ marginBottom: 12 }} rules={ isBytes