Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 49 additions & 26 deletions backend/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 5 additions & 5 deletions backend/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
17 changes: 8 additions & 9 deletions backend/handlers/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "",
})
}

Expand All @@ -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{
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 3 additions & 3 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 18 additions & 11 deletions docs/authentication/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>Google</strong> or <strong>Github</strong> as OAuth providers.
Expand All @@ -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:
Expand All @@ -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"
Expand All @@ -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
}
]
}
}
```
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -195,7 +199,7 @@ An example config of all authentication methods
"local_users": [
{
"username": "bob",
"password_hash": "$2a$10$...",
"password": "changeme",
"is_admin": false
}
],
Expand All @@ -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`.

Expand Down
6 changes: 3 additions & 3 deletions docs/authentication/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
```

Expand Down
4 changes: 2 additions & 2 deletions docs/configuration/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/features/simple-admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading