diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a020842 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [0.0.3-beta] - 2026-03-24 + +### Breaking changes + +- **User / OAuth config keys** (YAML, JSON, and `/api/config` payloads) use a consistent `oauth_*` prefix: + - `admin_emails` → `oauth_admin_emails` — OAuth accounts with admin access (and included in the sign-in allowlist). + - `allowed_oauth_emails` → `oauth_allowed_emails` — OAuth accounts that may sign in without admin (union with `oauth_admin_emails`). + - `allow_all_oauth_users` → `oauth_allow_all_users` — when `true`, any OAuth user with an email may sign in; email lists are ignored for sign-in (admin access still follows `oauth_admin_emails`). +- **Environment variables**: + - `ADMIN_EMAILS` → `OAUTH_ADMIN_EMAILS` (overrides `users.oauth_admin_emails`). + - `ALLOWED_OAUTH_EMAILS` → `OAUTH_ALLOWED_EMAILS` (overrides `users.oauth_allowed_emails`). + - `OAUTH_ALLOW_ALL_USERS` is unchanged and overrides `users.oauth_allow_all_users`. + +There is no automatic migration: update config files, env vars, and any automation that referenced the old names. + +### Added + +- OAuth sign-in **allowlist**: only emails in `oauth_admin_emails` ∪ `oauth_allowed_emails` can complete OAuth login unless `oauth_allow_all_users` is enabled. +- **Settings** UI and config schema for `oauth_allowed_emails` and `oauth_allow_all_users`, with a warning when OAuth is enabled but no allowlist is configured. +- **Startup logging** when Google/GitHub OAuth is enabled but the allowlist is empty (or when open OAuth sign-in is enabled). +- **Login** error query parameters `oauth_no_allowlist` and `oauth_not_allowed` with user-facing messages. +- `AuthHandler` reads user/OAuth settings from the live `*config.Config` so admin updates apply without restart where applicable. + +### Documentation + +- Examples, env reference, and auth docs updated for the new keys and variables. + +## [0.0.2-beta] - earlier + +Prior releases; see [GitHub Releases](https://github.com/heapoftrash/filetree/releases) for tags before this changelog was added. diff --git a/app/config/config.go b/app/config/config.go index 092e7bf..0d697a5 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -70,9 +70,11 @@ type FrontendConfig struct { } type UsersConfig struct { - AdminEmails []string `yaml:"admin_emails" json:"admin_emails"` - LocalUsers []LocalUser `yaml:"local_users" json:"local_users"` - DefaultAdmin *DefaultAdminUser `yaml:"default_admin" json:"default_admin"` + OauthAdminEmails []string `yaml:"oauth_admin_emails" json:"oauth_admin_emails"` // OAuth admins; union with oauth_allowed_emails for sign-in allowlist + OauthAllowedEmails []string `yaml:"oauth_allowed_emails" json:"oauth_allowed_emails"` // non-admin OAuth users allowed to sign in + OauthAllowAllUsers bool `yaml:"oauth_allow_all_users" json:"oauth_allow_all_users"` // if true, skip email allowlist for OAuth sign-in (oauth_admin_emails still gates admin) + LocalUsers []LocalUser `yaml:"local_users" json:"local_users"` + DefaultAdmin *DefaultAdminUser `yaml:"default_admin" json:"default_admin"` } type LocalUser struct { @@ -86,6 +88,11 @@ type DefaultAdminUser struct { Password string `yaml:"password,omitempty" json:"password,omitempty"` // plaintext or bcrypt; hashed on first run if plaintext } +func parseBoolEnv(s string) bool { + s = strings.TrimSpace(strings.ToLower(s)) + return s == "1" || s == "true" || s == "yes" || s == "on" +} + // 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$")) @@ -146,8 +153,14 @@ func Load(configPath string) (*Config, error) { if len(c.Frontend.CORSOrigins) > 0 { src.set("frontend.cors_origins", SourceConfig) } - if len(c.Users.AdminEmails) > 0 { - src.set("users.admin_emails", SourceConfig) + if len(c.Users.OauthAdminEmails) > 0 { + src.set("users.oauth_admin_emails", SourceConfig) + } + if len(c.Users.OauthAllowedEmails) > 0 { + src.set("users.oauth_allowed_emails", SourceConfig) + } + if c.Users.OauthAllowAllUsers { + src.set("users.oauth_allow_all_users", SourceConfig) } if c.Auth.Providers != nil { if p, ok := c.Auth.Providers["google"]; ok && p.ClientID != "" { @@ -228,13 +241,25 @@ func Load(configPath string) (*Config, error) { } src.set("frontend.cors_origins", SourceEnv) } - if v := os.Getenv("ADMIN_EMAILS"); v != "" { + if v := os.Getenv("OAUTH_ADMIN_EMAILS"); v != "" { + parts := strings.Split(v, ",") + for i, p := range parts { + parts[i] = strings.TrimSpace(p) + } + c.Users.OauthAdminEmails = parts + src.set("users.oauth_admin_emails", SourceEnv) + } + if v := os.Getenv("OAUTH_ALLOWED_EMAILS"); v != "" { parts := strings.Split(v, ",") for i, p := range parts { parts[i] = strings.TrimSpace(p) } - c.Users.AdminEmails = parts - src.set("users.admin_emails", SourceEnv) + c.Users.OauthAllowedEmails = parts + src.set("users.oauth_allowed_emails", SourceEnv) + } + if v := os.Getenv("OAUTH_ALLOW_ALL_USERS"); v != "" { + c.Users.OauthAllowAllUsers = parseBoolEnv(v) + src.set("users.oauth_allow_all_users", SourceEnv) } // 3. Defaults @@ -260,8 +285,14 @@ func Load(configPath string) (*Config, error) { if src["frontend.url"] == 0 { src.set("frontend.url", SourceDefault) } - if src["users.admin_emails"] == 0 { - src.set("users.admin_emails", SourceDefault) + if src["users.oauth_admin_emails"] == 0 { + src.set("users.oauth_admin_emails", SourceDefault) + } + if src["users.oauth_allowed_emails"] == 0 { + src.set("users.oauth_allowed_emails", SourceDefault) + } + if src["users.oauth_allow_all_users"] == 0 { + src.set("users.oauth_allow_all_users", SourceDefault) } if src["server.debug"] == 0 { c.Server.Debug = false @@ -405,10 +436,80 @@ func logConfigSources(logger *log.Logger, c *Config, src sources, configPath str {"auth.oauth_redirect_url", c.Auth.OAuthRedirectURL, src["auth.oauth_redirect_url"]}, {"frontend.url", c.Frontend.URL, src["frontend.url"]}, {"frontend.cors_origins", fmt.Sprintf("%v", c.Frontend.CORSOrigins), src["frontend.cors_origins"]}, - {"users.admin_emails", fmt.Sprintf("%v", c.Users.AdminEmails), src["users.admin_emails"]}, + {"users.oauth_admin_emails", fmt.Sprintf("%v", c.Users.OauthAdminEmails), src["users.oauth_admin_emails"]}, + {"users.oauth_allowed_emails", fmt.Sprintf("%v", c.Users.OauthAllowedEmails), src["users.oauth_allowed_emails"]}, + {"users.oauth_allow_all_users", fmt.Sprintf("%v", c.Users.OauthAllowAllUsers), src["users.oauth_allow_all_users"]}, } for _, item := range items { logger.Printf(" %s: %s (from %s)", item.key, item.value, item.s) } } + +// OAuthProviderActive reports whether Google or GitHub is enabled with a client ID. +func OAuthProviderActive(c *Config) bool { + if c == nil || c.Auth.Providers == nil { + return false + } + for _, id := range []string{"google", "github"} { + p, ok := c.Auth.Providers[id] + if ok && p.Enabled && strings.TrimSpace(p.ClientID) != "" { + return true + } + } + return false +} + +// OAuthLoginAllowlistConfigured returns true if OAuth sign-in is allowed without empty-list denial: +// oauth_allow_all_users, or at least one non-empty email in oauth_admin_emails or oauth_allowed_emails. +func OAuthLoginAllowlistConfigured(c *Config) bool { + if c == nil { + return false + } + if c.Users.OauthAllowAllUsers { + return true + } + for _, e := range c.Users.OauthAdminEmails { + if strings.TrimSpace(e) != "" { + return true + } + } + for _, e := range c.Users.OauthAllowedEmails { + if strings.TrimSpace(e) != "" { + return true + } + } + return false +} + +// UserIsAdmin reports whether identity (OAuth email or local username from JWT) has admin +// privileges for the given live config: oauth_admin_emails, local_users with is_admin, or +// default_admin when its password is set. +func UserIsAdmin(c *Config, identity string) bool { + if c == nil { + return false + } + identity = strings.TrimSpace(identity) + if identity == "" { + return false + } + for _, e := range c.Users.OauthAdminEmails { + if strings.EqualFold(strings.TrimSpace(e), identity) { + return true + } + } + localUsers := c.Users.LocalUsers + if localUsers == nil { + localUsers = []LocalUser{} + } + for _, u := range localUsers { + if strings.EqualFold(u.Username, identity) && u.IsAdmin { + return true + } + } + da := c.Users.DefaultAdmin + if da != nil && da.Password != "" && strings.EqualFold(da.Username, identity) { + return true + } + return false +} diff --git a/app/config/live.go b/app/config/live.go new file mode 100644 index 0000000..973c305 --- /dev/null +++ b/app/config/live.go @@ -0,0 +1,36 @@ +package config + +import "sync/atomic" + +// LiveConfig holds the current application config with atomic snapshot reads and publishes. +// Callers must treat Snapshot() return values as read-only; Replace swaps in a new pointer +// that must not be mutated after publish. +type LiveConfig struct { + v atomic.Value // *Config +} + +// NewLiveConfig wraps cfg for concurrent access. The same pointer is stored until the first Replace. +func NewLiveConfig(cfg *Config) *LiveConfig { + l := &LiveConfig{} + if cfg == nil { + l.v.Store(&Config{}) + } else { + l.v.Store(cfg) + } + return l +} + +// Snapshot returns the current config for read-only use. It is safe to call from any goroutine +// concurrently with Replace: each load observes one complete published config. +func (l *LiveConfig) Snapshot() *Config { + return l.v.Load().(*Config) +} + +// Replace publishes a new config. c must not be mutated after this call. +func (l *LiveConfig) Replace(c *Config) { + if c == nil { + l.v.Store(&Config{}) + return + } + l.v.Store(c) +} diff --git a/app/config/live_test.go b/app/config/live_test.go new file mode 100644 index 0000000..5417499 --- /dev/null +++ b/app/config/live_test.go @@ -0,0 +1,34 @@ +package config + +import ( + "sync" + "testing" +) + +func TestLiveConfigReplaceSnapshot(t *testing.T) { + a := &Config{Users: UsersConfig{OauthAdminEmails: []string{"a@b.c"}}} + l := NewLiveConfig(a) + if s := l.Snapshot(); len(s.Users.OauthAdminEmails) != 1 { + t.Fatalf("snapshot: %v", s.Users.OauthAdminEmails) + } + b := &Config{Users: UsersConfig{OauthAdminEmails: []string{"x@y.z"}}} + l.Replace(b) + if s := l.Snapshot(); len(s.Users.OauthAdminEmails) != 1 || s.Users.OauthAdminEmails[0] != "x@y.z" { + t.Fatalf("after replace: %v", s.Users.OauthAdminEmails) + } +} + +func TestLiveConfigConcurrentReplace(t *testing.T) { + l := NewLiveConfig(&Config{}) + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c := &Config{Users: UsersConfig{OauthAdminEmails: []string{"u@x.y"}}} + l.Replace(c) + _ = l.Snapshot().Users.OauthAdminEmails + }() + } + wg.Wait() +} diff --git a/app/config/oauth_allowlist_test.go b/app/config/oauth_allowlist_test.go new file mode 100644 index 0000000..e85fe6e --- /dev/null +++ b/app/config/oauth_allowlist_test.go @@ -0,0 +1,70 @@ +package config + +import "testing" + +func TestOAuthProviderActive(t *testing.T) { + c := &Config{} + if OAuthProviderActive(c) { + t.Fatal("expected false for nil providers") + } + c.Auth.Providers = map[string]ProviderConfig{ + "google": {Enabled: true, ClientID: "x"}, + } + if !OAuthProviderActive(c) { + t.Fatal("expected true when google enabled with client id") + } +} + +func TestOAuthLoginAllowlistConfigured(t *testing.T) { + if OAuthLoginAllowlistConfigured(nil) { + t.Fatal("nil config") + } + c := &Config{Users: UsersConfig{OauthAdminEmails: []string{" "}}} + if OAuthLoginAllowlistConfigured(c) { + t.Fatal("whitespace only should not count") + } + c.Users.OauthAdminEmails = []string{"a@b.c"} + if !OAuthLoginAllowlistConfigured(c) { + t.Fatal("admin email should count") + } + c.Users.OauthAdminEmails = nil + c.Users.OauthAllowedEmails = []string{"u@x.y"} + if !OAuthLoginAllowlistConfigured(c) { + t.Fatal("oauth_allowed_emails should count") + } + c.Users.OauthAllowedEmails = nil + c.Users.OauthAllowAllUsers = true + if !OAuthLoginAllowlistConfigured(c) { + t.Fatal("oauth_allow_all_users should satisfy allowlist check") + } +} + +func TestUserIsAdmin(t *testing.T) { + if UserIsAdmin(nil, "a@b.c") { + t.Fatal("nil config") + } + c := &Config{Users: UsersConfig{OauthAdminEmails: []string{"Admin@x.com"}}} + if !UserIsAdmin(c, "admin@x.com") { + t.Fatal("oauth admin email") + } + if UserIsAdmin(c, "other@x.com") { + t.Fatal("non-admin oauth email") + } + c.Users.OauthAdminEmails = nil + c.Users.LocalUsers = []LocalUser{{Username: "alice", IsAdmin: true}} + if !UserIsAdmin(c, "alice") { + t.Fatal("local admin username") + } + if UserIsAdmin(c, "bob") { + t.Fatal("non-admin local user") + } + c.Users.LocalUsers = []LocalUser{{Username: "alice", IsAdmin: false}} + c.Users.DefaultAdmin = &DefaultAdminUser{Username: "bootstrap", Password: "hashed"} + if !UserIsAdmin(c, "bootstrap") { + t.Fatal("default admin when password set") + } + c.Users.DefaultAdmin.Password = "" + if UserIsAdmin(c, "bootstrap") { + t.Fatal("default admin without password should not grant admin") + } +} diff --git a/app/config/registry.go b/app/config/registry.go index 00bbe7d..99895ac 100644 --- a/app/config/registry.go +++ b/app/config/registry.go @@ -68,7 +68,9 @@ var ConfigFields = []FieldMeta{ {Section: "auth", Key: "jwt_secret_set", Kind: FieldBool, Label: "JWT secret", Editable: false, Secret: true}, {Section: "auth", Key: "local_auth_enabled", Kind: FieldBool, Label: "Local users enabled", Editable: true}, // Users - {Section: "users", Key: "admin_emails", Kind: FieldStringSlice, Label: "Admin emails (OAuth)", Editable: true}, + {Section: "users", Key: "oauth_admin_emails", Kind: FieldStringSlice, Label: "Admins (OAuth)", Editable: true, Extra: "Full admin access. These addresses can sign in with Google or GitHub."}, + {Section: "users", Key: "oauth_allowed_emails", Kind: FieldStringSlice, Label: "Additional sign-ins (OAuth)", Editable: true, Extra: "Regular users who may sign in with OAuth (not admins)."}, + {Section: "users", Key: "oauth_allow_all_users", Kind: FieldBool, Label: "Allow all OAuth users", Editable: true, Extra: "Any OAuth user with an email can sign in; the lists above are ignored for sign-in. Admin access still follows the admin list only. Trusted environments only."}, {Section: "users", Key: "local_users", Kind: FieldObjectSlice, Label: "Local users", Editable: true}, {Section: "users", Key: "default_admin_username", Kind: FieldString, Label: "Default admin username", Editable: true, Placeholder: "admin"}, {Section: "users", Key: "default_admin_password", Kind: FieldString, Label: "Default admin password", Editable: true, Secret: true, Placeholder: "Only used when no users exist"}, diff --git a/app/handlers/auth.go b/app/handlers/auth.go index ff1bb20..abf492f 100644 --- a/app/handlers/auth.go +++ b/app/handlers/auth.go @@ -26,14 +26,9 @@ var ( ) type AuthHandler struct { - oauth2Configs map[string]*oauth2.Config // provider id -> config - jwtSecret []byte - frontendURL string - adminEmails []string - localUsers []config.LocalUser - defaultAdmin *config.DefaultAdminUser - localAuthEnabled bool - oauthProviders []LoginProviderInfo + oauth2Configs map[string]*oauth2.Config // provider id -> config + jwtSecret []byte + live *config.LiveConfig // atomic snapshots; same store as ConfigHandler } // oauthCallbackURL derives the callback URL for a provider from the base oauth_redirect_url. @@ -50,7 +45,8 @@ func oauthCallbackURL(baseRedirectURL, provider string) string { return baseRedirectURL[:idx] + "/api/auth/" + provider + "/callback" } -func NewAuthHandler(cfg *config.Config) *AuthHandler { +func NewAuthHandler(live *config.LiveConfig) *AuthHandler { + cfg := live.Snapshot() baseRedirectURL := strings.TrimSpace(cfg.Auth.OAuthRedirectURL) oauth2Configs := make(map[string]*oauth2.Config) if cfg.Auth.Providers != nil { @@ -87,24 +83,26 @@ func NewAuthHandler(cfg *config.Config) *AuthHandler { } } } - localUsers := cfg.Users.LocalUsers - if localUsers == nil { - localUsers = []config.LocalUser{} - } - oauthProviders := buildOAuthProviders(cfg) - hasLocalAuth := cfg.Auth.LocalAuthEnabled && (len(localUsers) > 0 || (cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.Password != "")) return &AuthHandler{ - oauth2Configs: oauth2Configs, - jwtSecret: []byte(cfg.Auth.JWTSecret), - frontendURL: cfg.Frontend.URL, - adminEmails: cfg.Users.AdminEmails, - localUsers: localUsers, - defaultAdmin: cfg.Users.DefaultAdmin, - localAuthEnabled: hasLocalAuth, - oauthProviders: oauthProviders, + oauth2Configs: oauth2Configs, + jwtSecret: []byte(cfg.Auth.JWTSecret), + live: live, } } +func oauthEmailAllowSet(adminEmails, allowedOAuthEmails []string) map[string]struct{} { + out := make(map[string]struct{}) + for _, list := range [][]string{adminEmails, allowedOAuthEmails} { + for _, e := range list { + e = strings.ToLower(strings.TrimSpace(e)) + if e != "" { + out[e] = struct{}{} + } + } + } + return out +} + func buildOAuthProviders(cfg *config.Config) []LoginProviderInfo { out := make([]LoginProviderInfo, 0) if cfg.Auth.Providers == nil { @@ -131,9 +129,15 @@ type LoginProviderInfo struct { // LoginOptions returns enabled auth methods (public, no JWT). func (h *AuthHandler) LoginOptions(c *gin.Context) { + cfg := h.live.Snapshot() + localUsers := cfg.Users.LocalUsers + if localUsers == nil { + localUsers = []config.LocalUser{} + } + hasLocalAuth := cfg.Auth.LocalAuthEnabled && (len(localUsers) > 0 || (cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.Password != "")) c.JSON(http.StatusOK, LoginOptionsResponse{ - LocalAuthEnabled: h.localAuthEnabled, - Providers: h.oauthProviders, + LocalAuthEnabled: hasLocalAuth, + Providers: buildOAuthProviders(cfg), }) } @@ -145,7 +149,13 @@ type LocalLoginRequest struct { // LocalLogin authenticates a local user or default admin and returns JWT. func (h *AuthHandler) LocalLogin(c *gin.Context) { - if !h.localAuthEnabled { + cfg := h.live.Snapshot() + localUsers := cfg.Users.LocalUsers + if localUsers == nil { + localUsers = []config.LocalUser{} + } + hasLocalAuth := cfg.Auth.LocalAuthEnabled && (len(localUsers) > 0 || (cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.Password != "")) + if !hasLocalAuth { c.JSON(http.StatusBadRequest, gin.H{"error": "local auth not enabled"}) return } @@ -162,26 +172,29 @@ func (h *AuthHandler) LocalLogin(c *gin.Context) { } var authUsername string // 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].Password), []byte(password)); err != nil { + for i := range localUsers { + if strings.EqualFold(localUsers[i].Username, username) { + if err := bcrypt.CompareHashAndPassword([]byte(localUsers[i].Password), []byte(password)); err != nil { + log.Printf("[auth] local user login failed: username=%q reason=invalid_credentials", localUsers[i].Username) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } - authUsername = h.localUsers[i].Username + authUsername = localUsers[i].Username break } } - // Check default_admin if not found in local_users - if authUsername == "" && h.defaultAdmin != nil && h.defaultAdmin.Password != "" && - strings.EqualFold(h.defaultAdmin.Username, username) { - if err := bcrypt.CompareHashAndPassword([]byte(h.defaultAdmin.Password), []byte(password)); err != nil { + da := cfg.Users.DefaultAdmin + if authUsername == "" && da != nil && da.Password != "" && + strings.EqualFold(da.Username, username) { + if err := bcrypt.CompareHashAndPassword([]byte(da.Password), []byte(password)); err != nil { + log.Printf("[auth] local user login failed: username=%q reason=invalid_credentials", da.Username) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } - authUsername = h.defaultAdmin.Username + authUsername = da.Username } if authUsername == "" { + log.Printf("[auth] local user login failed: username=%q reason=unknown_user", username) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } @@ -280,6 +293,27 @@ func (h *AuthHandler) oauthCallback(provider string, c *gin.Context) { return } + appCfg := h.live.Snapshot() + emailKey := strings.ToLower(strings.TrimSpace(email)) + if emailKey == "" { + log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_email_missing", provider, email, name) + c.Redirect(http.StatusFound, h.redirectTo("/login?error=userinfo")) + return + } + if !appCfg.Users.OauthAllowAllUsers { + allow := oauthEmailAllowSet(appCfg.Users.OauthAdminEmails, appCfg.Users.OauthAllowedEmails) + if len(allow) == 0 { + log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_no_allowlist_configured", provider, email, name) + c.Redirect(http.StatusFound, h.redirectTo("/login?error=oauth_no_allowlist")) + return + } + if _, ok := allow[emailKey]; !ok { + log.Printf("[auth] %s user login failed: email=%q name=%q reason=oauth_email_not_allowed", provider, email, name) + c.Redirect(http.StatusFound, h.redirectTo("/login?error=oauth_not_allowed")) + return + } + } + log.Printf("[auth] %s user logged in: email=%q name=%q", provider, email, name) claims := jwt.MapClaims{ @@ -396,7 +430,7 @@ func (h *AuthHandler) fetchGitHubPrimaryEmail(_ context.Context, client *http.Cl } func (h *AuthHandler) redirectTo(path string) string { - base := h.frontendURL + base := h.live.Snapshot().Frontend.URL if base == "" || base == "/" { return path } @@ -411,29 +445,12 @@ func (h *AuthHandler) redirectTo(path string) string { // Me returns the current user (requires auth middleware). func (h *AuthHandler) Me(c *gin.Context) { + cfg := h.live.Snapshot() email, _ := c.Get("user_email") name, _ := c.Get("user_name") picture, _ := c.Get("user_picture") emailStr, _ := email.(string) - isAdmin := false - for _, e := range h.adminEmails { - if strings.EqualFold(strings.TrimSpace(e), emailStr) { - isAdmin = true - break - } - } - if !isAdmin { - for _, u := range h.localUsers { - if strings.EqualFold(u.Username, emailStr) && u.IsAdmin { - isAdmin = true - break - } - } - } - if !isAdmin && h.defaultAdmin != nil && h.defaultAdmin.Password != "" && - strings.EqualFold(h.defaultAdmin.Username, emailStr) { - isAdmin = true - } + isAdmin := config.UserIsAdmin(cfg, emailStr) c.JSON(http.StatusOK, gin.H{ "email": email, "name": name, diff --git a/app/handlers/auth_allowlist_test.go b/app/handlers/auth_allowlist_test.go new file mode 100644 index 0000000..87296c5 --- /dev/null +++ b/app/handlers/auth_allowlist_test.go @@ -0,0 +1,23 @@ +package handlers + +import "testing" + +func TestOAuthEmailAllowSet(t *testing.T) { + m := oauthEmailAllowSet([]string{" A@x.com ", "b@y.com"}, []string{"B@y.com", ""}) + if len(m) != 2 { + t.Fatalf("expected 2 unique keys, got %d: %v", len(m), m) + } + if _, ok := m["a@x.com"]; !ok { + t.Fatal("missing a@x.com") + } + if _, ok := m["b@y.com"]; !ok { + t.Fatal("missing b@y.com") + } +} + +func TestOAuthEmailAllowSet_emptyLists(t *testing.T) { + m := oauthEmailAllowSet(nil, nil) + if len(m) != 0 { + t.Fatalf("expected empty set, got %v", m) + } +} diff --git a/app/handlers/config.go b/app/handlers/config.go index 89c687d..c9088c2 100644 --- a/app/handlers/config.go +++ b/app/handlers/config.go @@ -16,12 +16,12 @@ import ( // ConfigHandler serves config API (admin only). type ConfigHandler struct { - cfg *config.Config + live *config.LiveConfig } // NewConfigHandler returns a ConfigHandler. -func NewConfigHandler(cfg *config.Config) *ConfigHandler { - return &ConfigHandler{cfg: cfg} +func NewConfigHandler(live *config.LiveConfig) *ConfigHandler { + return &ConfigHandler{live: live} } // ConfigSection is a section in the config schema. @@ -75,7 +75,7 @@ type ConfigAPIResponse struct { // GetConfig returns schema + current values (admin only). func (h *ConfigHandler) GetConfig(c *gin.Context) { - cfg := h.cfg + cfg := h.live.Snapshot() // Build schema from registry sections := []ConfigSection{ @@ -170,7 +170,9 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) { }, "auth_providers": authProviders, "users": { - "admin_emails": cfg.Users.AdminEmails, + "oauth_admin_emails": cfg.Users.OauthAdminEmails, + "oauth_allowed_emails": cfg.Users.OauthAllowedEmails, + "oauth_allow_all_users": cfg.Users.OauthAllowAllUsers, "local_users": localUsersUI, "default_admin_username": defaultAdminUsername, "default_admin_password": defaultAdminPasswordSet, // true = Set, false = Not set @@ -192,22 +194,23 @@ type ConfigUpdateRequest map[string]interface{} // UpdateConfig updates config file (admin only). Server restart required for most changes. func (h *ConfigHandler) UpdateConfig(c *gin.Context) { - if h.cfg.ConfigPath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "config file not configured (CONFIG_FILE not set)"}) - return - } - var req ConfigUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - cfg := *h.cfg + cur := h.live.Snapshot() + if cur.ConfigPath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "config file not configured (CONFIG_FILE not set)"}) + return + } + + cfg := *cur // Deep copy Providers map to avoid mutating live config before file write succeeds, // and to prevent concurrent map read/write if GetConfig runs during UpdateConfig. cfg.Auth.Providers = make(map[string]config.ProviderConfig) - for k, v := range h.cfg.Auth.Providers { + for k, v := range cur.Auth.Providers { cfg.Auth.Providers[k] = v } @@ -271,7 +274,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { } } if s, ok := req["users"].(map[string]interface{}); ok { - if v, ok := s["admin_emails"]; ok { + if v, ok := s["oauth_admin_emails"]; ok { if arr, ok := toStringSlice(v); ok { emails := make([]string, 0, len(arr)) for _, e := range arr { @@ -280,9 +283,24 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { emails = append(emails, es) } } - cfg.Users.AdminEmails = emails + cfg.Users.OauthAdminEmails = emails } } + if v, ok := s["oauth_allowed_emails"]; ok { + if arr, ok := toStringSlice(v); ok { + emails := make([]string, 0, len(arr)) + for _, e := range arr { + es := strings.TrimSpace(e) + if es != "" { + emails = append(emails, es) + } + } + cfg.Users.OauthAllowedEmails = emails + } + } + if v, ok := s["oauth_allow_all_users"].(bool); ok { + cfg.Users.OauthAllowAllUsers = v + } if v, ok := s["local_users"]; ok { if arr, ok := toLocalUsers(v, cfg.Users.LocalUsers); ok { cfg.Users.LocalUsers = arr @@ -318,18 +336,20 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { } // Write to file - data, err := marshalConfig(&cfg, h.cfg.ConfigPath) + data, err := marshalConfig(&cfg, cur.ConfigPath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize config: " + err.Error()}) return } - if err := os.WriteFile(h.cfg.ConfigPath, data, 0600); err != nil { + if err := os.WriteFile(cur.ConfigPath, data, 0600); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config: " + err.Error()}) return } - // Update in-memory config - *h.cfg = cfg + // Publish new config atomically (readers use Snapshot; no in-place struct copy) + pub := new(config.Config) + *pub = cfg + h.live.Replace(pub) c.JSON(http.StatusOK, gin.H{"ok": true, "message": "Config saved. Server restart required for some changes."}) } diff --git a/app/main.go b/app/main.go index 42441bb..db5d214 100644 --- a/app/main.go +++ b/app/main.go @@ -20,6 +20,13 @@ func main() { log.Fatalf("config bootstrap: %v", err) } + if config.OAuthProviderActive(cfg) && !config.OAuthLoginAllowlistConfigured(cfg) { + log.Println("[config] OAuth provider(s) are enabled but users.oauth_admin_emails and users.oauth_allowed_emails are empty — OAuth sign-in will be denied until at least one email is listed (or set users.oauth_allow_all_users).") + } + if config.OAuthProviderActive(cfg) && cfg.Users.OauthAllowAllUsers { + log.Println("[config] users.oauth_allow_all_users is enabled — any OAuth user with a verified email may sign in; oauth_admin_emails still controls admin access only.") + } + if err := os.MkdirAll(cfg.Server.RootPath, 0750); err != nil { log.Fatalf("mkdir root: %v", err) } @@ -30,8 +37,9 @@ func main() { if err != nil { log.Fatalf("handlers: invalid root path: %v", err) } - authH := handlers.NewAuthHandler(cfg) - configH := handlers.NewConfigHandler(cfg) + live := config.NewLiveConfig(cfg) + authH := handlers.NewAuthHandler(live) + configH := handlers.NewConfigHandler(live) if !cfg.Server.Debug { gin.SetMode(gin.ReleaseMode) } @@ -51,17 +59,8 @@ func main() { // Protected auth route api.GET("/auth/me", middleware.Auth(), authH.Me) - // Admin-only config routes - localAdminUsernames := make([]string, 0) - for _, u := range cfg.Users.LocalUsers { - if u.IsAdmin { - localAdminUsernames = append(localAdminUsernames, u.Username) - } - } - 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)) + // Admin-only config routes (RequireAdmin reads live cfg on each request) + configGroup := api.Group("/config", middleware.Auth(), middleware.RequireAdmin(live)) configGroup.GET("", configH.GetConfig) configGroup.PATCH("", configH.UpdateConfig) diff --git a/app/middleware/admin.go b/app/middleware/admin.go index a1d44a2..66937f2 100644 --- a/app/middleware/admin.go +++ b/app/middleware/admin.go @@ -2,20 +2,15 @@ package middleware import ( "net/http" - "strings" "github.com/gin-gonic/gin" + + "github.com/heapoftrash/filetree/app/config" ) -// RequireAdmin returns a handler that aborts with 403 if user_email is not in adminEmails or localAdminUsernames. -func RequireAdmin(adminEmails []string, localAdminUsernames []string) gin.HandlerFunc { - adminSet := make(map[string]bool) - for _, e := range adminEmails { - adminSet[strings.ToLower(strings.TrimSpace(e))] = true - } - for _, u := range localAdminUsernames { - adminSet[strings.ToLower(strings.TrimSpace(u))] = true - } +// RequireAdmin returns a handler that aborts with 403 unless the authenticated identity is an +// admin according to the current config snapshot (same rules as AuthHandler.Me). +func RequireAdmin(live *config.LiveConfig) gin.HandlerFunc { return func(c *gin.Context) { emailVal, ok := c.Get("user_email") if !ok { @@ -23,7 +18,7 @@ func RequireAdmin(adminEmails []string, localAdminUsernames []string) gin.Handle return } email, _ := emailVal.(string) - if adminSet[strings.ToLower(email)] { + if config.UserIsAdmin(live.Snapshot(), email) { c.Next() return } diff --git a/app/web/src/components/Login.tsx b/app/web/src/components/Login.tsx index ff93b71..d05028d 100644 --- a/app/web/src/components/Login.tsx +++ b/app/web/src/components/Login.tsx @@ -15,6 +15,8 @@ const errorMessages: Record = { exchange: 'Failed to complete sign in.', userinfo: 'Failed to get user info.', token: 'Failed to create session.', + oauth_no_allowlist: 'User not allowed to sign in.', + oauth_not_allowed: 'User not allowed to sign in.', } export default function Login() { diff --git a/app/web/src/components/Settings.tsx b/app/web/src/components/Settings.tsx index 21cae70..59990dc 100644 --- a/app/web/src/components/Settings.tsx +++ b/app/web/src/components/Settings.tsx @@ -16,6 +16,10 @@ import { theme, Row, Col, + Alert, + Divider, + Modal, + Tooltip, } from 'antd' import { CloudServerOutlined, @@ -59,6 +63,13 @@ const ExtraWithIcon = ({ children }: { children: React.ReactNode }) => { ) } +/** Ant Design Form.Item: show long hints next to the label (hover) instead of an extra line. */ +function formItemTooltip(text?: string | null): { title: string; icon: React.ReactElement } | undefined { + const t = text?.trim() + if (!t) return undefined + return { title: t, icon: } +} + const TOP_LEVEL_SECTIONS = [ { id: 'server', label: 'Server' }, { id: 'auth_providers', label: 'Auth Providers' }, @@ -71,8 +82,8 @@ const AUTH_PROVIDER_MENU_ITEMS = [ ] as const const USERS_MENU_ITEMS = [ - { key: 'admin_user', label: 'Admin user', icon: }, - { key: 'local_user', label: 'Local user', icon: }, + { key: 'admin_user', label: 'OAuth', icon: }, + { key: 'local_user', label: 'Local', icon: }, ] as const const SECTION_OPTIONS = TOP_LEVEL_SECTIONS.map((s) => ({ @@ -81,6 +92,12 @@ const SECTION_OPTIONS = TOP_LEVEL_SECTIONS.map((s) => ({ icon: SECTION_ICONS[s.id], })) +/** Settings form: label left, control right; stacks on narrow viewports. */ +const SETTINGS_FORM_LAYOUT = { + labelCol: { xs: 24, sm: 9, md: 8, lg: 7 }, + wrapperCol: { xs: 24, sm: 15, md: 16, lg: 17 }, +} + type AuthProviderSubSection = 'google' | 'github' type UsersSubSection = 'admin_user' | 'local_user' @@ -95,7 +112,7 @@ function buildFormValues(res: ConfigAPIResponse): Record { const field = res.schema.fields?.find((f) => f.section === section && f.key === key) if (field?.kind === 'bytes' && typeof val === 'number') { sectionOut[key] = bytesToHuman(val) - } else if (field?.kind === 'string[]' && key === 'admin_emails') { + } else if (field?.kind === 'string[]' && (key === 'oauth_admin_emails' || key === 'oauth_allowed_emails')) { const arr = Array.isArray(val) ? (val as string[]) : [] sectionOut[key] = arr.length ? arr : [''] } else if (key === 'default_admin_password') { @@ -139,9 +156,12 @@ function categoryForField(name: (string | number)[]): { if (first === 'auth' || first === 'auth_providers') return { section: 'auth_providers', authProviderSub: 'google' } if (first === 'users') { const key = name[1] - if (key === 'admin_emails' || key === 'default_admin_username' || key === 'default_admin_password') { + if (key === 'oauth_admin_emails' || key === 'oauth_allowed_emails' || key === 'oauth_allow_all_users') { return { section: 'users', usersSub: 'admin_user' } } + if (key === 'default_admin_username' || key === 'default_admin_password') { + return { section: 'users', usersSub: 'local_user' } + } return { section: 'users', usersSub: 'local_user' } } return { section: 'auth_providers', authProviderSub: 'google' } @@ -165,12 +185,22 @@ export default function Settings() { const authProviders = Form.useWatch(['auth_providers'], form) ?? (config?.values as Record>)?.auth_providers const localUsersFormValues = (Form.useWatch(['users', 'local_users'], form) ?? []) as Array> + const oauthAdminEmailsWatch = Form.useWatch(['users', 'oauth_admin_emails'], form) + const oauthAllowedEmailsWatch = Form.useWatch(['users', 'oauth_allowed_emails'], form) + const oauthAllowAllUsersWatch = Form.useWatch(['users', 'oauth_allow_all_users'], form) const oauthEnabled = (authProviders && typeof authProviders === 'object' && ((authProviders as Record>).google?.enabled === true || (authProviders as Record>).github?.enabled === true)) ?? false + const oauthSignInOk = useMemo(() => { + if (oauthAllowAllUsersWatch === true) return true + const countNonEmpty = (arr: unknown) => + Array.isArray(arr) ? (arr as unknown[]).filter((e) => typeof e === 'string' && e.trim()).length : 0 + return countNonEmpty(oauthAdminEmailsWatch) + countNonEmpty(oauthAllowedEmailsWatch) > 0 + }, [oauthAllowAllUsersWatch, oauthAdminEmailsWatch, oauthAllowedEmailsWatch]) + useEffect(() => { getConfig() .then((res) => { @@ -186,25 +216,6 @@ export default function Settings() { if (!config) return setSaving(true) try { - // Validate admin_emails only when OAuth is enabled - const authProvidersVal = values.auth_providers as Record> | undefined - const hasOAuth = - authProvidersVal && - (authProvidersVal.google?.enabled === true || authProvidersVal.github?.enabled === true) - const users = values.users as Record | undefined - if (hasOAuth && users?.admin_emails !== undefined) { - const emails = (Array.isArray(users.admin_emails) ? users.admin_emails : []).filter( - (e: unknown) => typeof e === 'string' && e.trim(), - ) - if (emails.length === 0) { - message.error('At least one admin email is required when OAuth is enabled') - setActiveSection('users') - setUsersSub('admin_user') - setSaving(false) - return - } - } - const payload: Record = {} const schema = config.schema @@ -306,14 +317,20 @@ export default function Settings() { } }, [fieldsBySection.auth]) - const adminUserFields = useMemo(() => { + const oauthAdminUserFields = useMemo(() => { const users = fieldsBySection.users ?? [] + const order = ['oauth_admin_emails', 'oauth_allowed_emails', 'oauth_allow_all_users'] return users - .filter((f) => ['admin_emails', 'default_admin_username', 'default_admin_password'].includes(f.key)) - .sort((a, b) => { - const order = ['admin_emails', 'default_admin_username', 'default_admin_password'] - return order.indexOf(a.key) - order.indexOf(b.key) - }) + .filter((f) => order.includes(f.key)) + .sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key)) + }, [fieldsBySection.users]) + + const defaultAdminBootstrapFields = useMemo(() => { + const users = fieldsBySection.users ?? [] + const order = ['default_admin_username', 'default_admin_password'] + return users + .filter((f) => order.includes(f.key)) + .sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key)) }, [fieldsBySection.users]) const localUsersFields = useMemo( @@ -339,11 +356,11 @@ export default function Settings() { return ( - + Settings - + setActiveSection(v as string)} @@ -357,7 +374,10 @@ export default function Settings() {
{usersSub === 'admin_user' && ( <> - {adminUserFields.map((field) => ( + {oauthEnabled && !oauthSignInOk && ( + + )} + + OAuth sign-in + + + Who may use Google or GitHub to sign in. If both lists are empty and allow-all is off, OAuth login is blocked. + + {oauthAdminUserFields.map((field) => ( ))} + {oauthAllowAllUsersWatch === true && ( + + )} )} {usersSub === 'local_user' && ( @@ -457,6 +501,24 @@ export default function Settings() { localUsersFormValues={localUsersFormValues} /> ))} + + + Default admin (bootstrap) + + + Used when no local users exist yet. Password is stored hashed in config after first startup. + + {defaultAdminBootstrapFields.map((field) => ( + + ))} )} @@ -477,6 +539,33 @@ export default function Settings() { ) } +/** Switch for oauth_allow_all_users: confirm before enabling open sign-in. */ +function OAuthAllowAllSwitch({ checked, onChange }: { checked?: boolean; onChange?: (checked: boolean) => void }) { + return ( + { + if (checked) { + Modal.confirm({ + title: 'Allow any OAuth user to sign in?', + content: + 'Email lists will not restrict who can sign in with OAuth. Admin access still follows the admin list only. Use only in trusted environments.', + okText: 'Enable', + okType: 'danger', + cancelText: 'Cancel', + onOk: () => onChange?.(true), + }) + } else { + onChange?.(false) + } + }} + /> + ) +} + const ProviderSection = React.memo(function ProviderSection({ providerSection, values, @@ -527,7 +616,7 @@ const ProviderField = React.memo(function ProviderField({ {clientSecretSet ? 'Value is set. Cannot be changed after save.' : 'Enter client secret to save.'}} + tooltip={formItemTooltip(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 }}> + ) @@ -558,17 +647,156 @@ const ProviderField = React.memo(function ProviderField({ } return ( - {field.extra} : undefined} - style={{ marginBottom: 12 }} - > + ) }) +/** Single card: title + Add user in header; each row is username, password, admin (horizontal). */ +const LocalUsersListField = React.memo(function LocalUsersListField({ + namePath, + initialList, + listForPasswordFlag, + localUsersFormValues, + localAuthEnabled, + label, + tooltip, +}: { + namePath: (string | number)[] + initialList: Array<{ username: string; password: string; is_admin: boolean; password_set?: boolean }> + listForPasswordFlag: Array> + localUsersFormValues: Array> + localAuthEnabled: boolean + label: string + tooltip: ReturnType +}) { + /** Reserve enough label width so horizontal labels don’t collide with middle-sized inputs. */ + const cellLayout = { + labelCol: { flex: '0 0 112px', style: { overflow: 'visible' as const } }, + wrapperCol: { flex: '1 1 0', minWidth: 0, style: { minWidth: 0 } }, + } + const adminLayout = { + labelCol: { flex: '0 0 52px', style: { overflow: 'visible' as const } }, + wrapperCol: { flex: '0 0 auto' }, + } + + const cardTitle = ( + + {label} + {tooltip ? ( + + {tooltip.icon} + + ) : null} + + ) + + return ( + +
+ + {(fields, { add, remove }) => ( + } + onClick={() => add({ username: '', password: '', is_admin: false })} + disabled={!localAuthEnabled} + > + Add user + + } + styles={{ body: { paddingBlock: 16 } }} + > + {fields.map(({ key, name, ...restField }, index) => { + const item = listForPasswordFlag[name] ?? localUsersFormValues?.[name] ?? {} + const passwordSet = !!item?.password_set + return ( + + + + + + + + + + + + + + + + + + +
+
+ ) +}) + const ConfigField = React.memo(function ConfigField({ sectionId, field, @@ -586,14 +814,13 @@ const ConfigField = React.memo(function ConfigField({ addButtonPosition?: 'right' localUsersFormValues?: Array> }) { - const { token } = theme.useToken() const sectionValues = values[sectionId] ?? {} const rawValue = sectionValues[field.key] if (field.secret && !field.editable) { const isSet = !!rawValue return ( - {isSet ? 'Value is set.' : 'Not set.'}} style={{ marginBottom: 12 }}> + ) @@ -605,7 +832,7 @@ const ConfigField = React.memo(function ConfigField({ {isSet ? 'Value is set. Enter a new value to change it.' : 'Enter password for first-time setup.'}} + tooltip={formItemTooltip(isSet ? 'Value is set. Enter a new value to change it.' : 'Enter password for first-time setup.')} style={{ marginBottom: 12 }} > @@ -616,6 +843,19 @@ const ConfigField = React.memo(function ConfigField({ const namePath = [sectionId, field.key] if (field.kind === 'bool') { + if (field.key === 'oauth_allow_all_users') { + return ( + + + + ) + } return ( @@ -626,27 +866,22 @@ const ConfigField = React.memo(function ConfigField({ if (field.kind === 'string[]') { const arr = Array.isArray(rawValue) ? rawValue : [] const list = arr.filter((x): x is string => typeof x === 'string') - const isAdminEmails = field.key === 'admin_emails' - const adminEmailsDisabled = isAdminEmails && !oauthEnabled - const initialList = isAdminEmails && list.length === 0 ? [''] : list + const isOAuthEmailList = field.key === 'oauth_admin_emails' || field.key === 'oauth_allowed_emails' + const oauthListDisabled = isOAuthEmailList && !oauthEnabled + const initialList = isOAuthEmailList && list.length === 0 ? [''] : list + const oauthPlaceholder = field.key === 'oauth_admin_emails' ? 'admin@example.com' : 'user@example.com' const labelInRow = addButtonPosition === 'right' + const oauthListTooltip = oauthListDisabled + ? 'Enable an OAuth provider (Google or GitHub) to configure OAuth email lists.' + : field.extra return ( - Enable an OAuth provider (Google or GitHub) to add admin emails. : undefined} - style={{ marginBottom: 12 }} - > + {(fields, { add, remove }, { errors }) => ( <> {labelInRow && ( -
- - {field.label} - {isAdminEmails && oauthEnabled && *} - -
@@ -657,17 +892,16 @@ const ConfigField = React.memo(function ConfigField({ - @@ -675,7 +909,7 @@ const ConfigField = React.memo(function ConfigField({ {!labelInRow && (
-
@@ -691,98 +925,21 @@ const ConfigField = React.memo(function ConfigField({ if (field.kind === 'object[]' && field.key === 'local_users') { const arr = Array.isArray(rawValue) ? rawValue : [] const list = arr.map((u: Record) => ({ - username: u.username ?? '', + username: typeof u.username === 'string' ? u.username : '', password: '', - is_admin: u.is_admin ?? false, + is_admin: typeof u.is_admin === 'boolean' ? u.is_admin : false, password_set: !!u.password_set, })) - const addInLabelRow = addButtonPosition === 'right' return ( - Enable "Local users enabled" above to add local users. : undefined} - > - - {(fields, { add, remove }) => ( - <> - {addInLabelRow && ( -
- {field.label} - -
- )} - {fields.map(({ key, name, ...restField }) => { - const item = list[name] ?? localUsersFormValues?.[name] ?? {} - const passwordSet = !!item?.password_set - return ( - - - - - - - - - - Value is set. Enter a new value to change it. : undefined} - style={{ marginBottom: 0 }} - > - - - - - - - - - - - - - - - - - )})} - {!addInLabelRow && ( - -
- -
-
- )} - - )} -
-
+ ) } @@ -791,7 +948,7 @@ const ConfigField = React.memo(function ConfigField({ {field.extra} : undefined} + tooltip={formItemTooltip(field.extra)} style={{ marginBottom: 12 }} rules={ isBytes diff --git a/config.example.json b/config.example.json index c7c5ae0..0cf5076 100644 --- a/config.example.json +++ b/config.example.json @@ -21,7 +21,9 @@ "url": "http://localhost:5173" }, "users": { - "admin_emails": [], + "oauth_admin_emails": [], + "oauth_allowed_emails": [], + "oauth_allow_all_users": false, "local_users": [], "default_admin": null } diff --git a/config.example.yaml b/config.example.yaml index 15f426b..abc83b3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -26,7 +26,9 @@ frontend: url: http://localhost:5173 users: - admin_emails: [] # OAuth user emails that get admin role + oauth_admin_emails: [] # OAuth emails: admin + allowed to sign in + oauth_allowed_emails: [] # OAuth emails: non-admin users allowed to sign in (union with oauth_admin_emails). If OAuth is enabled and both lists empty, OAuth sign-in is denied unless oauth_allow_all_users is true. + oauth_allow_all_users: false # If true, any OAuth user with an email may sign in (lists ignored for sign-in). Trusted environments only. local_users: [] # Local username/password users # default_admin: bootstrap on startup (password hashed, stored in default_admin; not added to local_users) # default_admin: diff --git a/docs/authentication/index.md b/docs/authentication/index.md index a2412ba..79134cf 100644 --- a/docs/authentication/index.md +++ b/docs/authentication/index.md @@ -78,7 +78,7 @@ Setup Google as OAuth provider client_secret: xxx users: - admin_emails: [admin@example.com] + oauth_admin_emails: [admin@example.com] ``` === "JSON" @@ -96,7 +96,7 @@ Setup Google as OAuth provider } }, "users": { - "admin_emails": [ + "oauth_admin_emails": [ "admin@example.com" ] } @@ -119,7 +119,7 @@ Setup Google as OAuth provider client_secret: xxx users: - admin_emails: [admin@example.com] + oauth_admin_emails: [admin@example.com] ``` === "JSON" ```json title="config.json" @@ -136,7 +136,7 @@ Setup Google as OAuth provider } }, "users": { - "admin_emails": [ + "oauth_admin_emails": [ "admin@example.com" ] } @@ -163,7 +163,7 @@ An example config of all authentication methods client_secret: xxx users: - admin_emails: [admin@example.com] + oauth_admin_emails: [admin@example.com] local_users: - username: bob password: changeme # plaintext hashed on first run, or use bcrypt hash @@ -193,7 +193,7 @@ An example config of all authentication methods } }, "users": { - "admin_emails": [ + "oauth_admin_emails": [ "admin@example.com" ], "local_users": [ @@ -215,7 +215,10 @@ An example config of all authentication methods `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`. + `oauth_admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, set `is_admin: true` per user in `local_users`. + +!!! note "OAuth allowlist" + `oauth_allowed_emails` lists non-admin OAuth users who may sign in. The allowlist is `oauth_admin_emails` ∪ `oauth_allowed_emails`. If OAuth is enabled and both are empty, OAuth sign-in is blocked unless `oauth_allow_all_users` is true (open sign-in; trusted environments only). !!! note "OAuth redirect URL" diff --git a/docs/authentication/setup.md b/docs/authentication/setup.md index 840da4b..bdd5853 100644 --- a/docs/authentication/setup.md +++ b/docs/authentication/setup.md @@ -80,7 +80,10 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. ## Admin users !!! note "OAuth admins only" - `admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, use `is_admin: true` in `local_users`. + `oauth_admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, use `is_admin: true` in `local_users`. + +!!! note "OAuth who may sign in" + `oauth_allowed_emails` lists OAuth users who may sign in **without** admin. Together with `oauth_admin_emails`, they form the OAuth allowlist. If OAuth is enabled and both lists are empty, no OAuth sign-in is allowed. === "Config file" @@ -88,9 +91,11 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. ```yaml users: - admin_emails: + oauth_admin_emails: - admin@example.com - other-admin@example.com + oauth_allowed_emails: + - user@example.com ``` Local admins (per-user): @@ -107,10 +112,14 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`. | Variable | Overrides | Purpose | |----------|-----------|---------| - | `ADMIN_EMAILS` | `users.admin_emails` | Comma-separated OAuth admin emails | + | `OAUTH_ADMIN_EMAILS` | `users.oauth_admin_emails` | Comma-separated OAuth admin emails | + | `OAUTH_ALLOWED_EMAILS` | `users.oauth_allowed_emails` | Comma-separated OAuth non-admin allowed emails | + | `OAUTH_ALLOW_ALL_USERS` | `users.oauth_allow_all_users` | `true`/`1`/`yes`/`on` to allow any OAuth user to sign in | ```bash - export ADMIN_EMAILS="admin@example.com,other@example.com" + export OAUTH_ADMIN_EMAILS="admin@example.com,other@example.com" + export OAUTH_ALLOWED_EMAILS="user@example.com" + # export OAUTH_ALLOW_ALL_USERS=true # optional: open OAuth sign-in (trusted env only) ``` Local admins must use `is_admin: true` in config; no env override. diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index 3f425cc..fabb941 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -52,7 +52,9 @@ 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 | +| `oauth_admin_emails` | []string | OAuth emails with admin access; also counted as allowed to sign in | +| `oauth_allowed_emails` | []string | OAuth emails allowed to sign in as regular (non-admin) users; union with `oauth_admin_emails`. If OAuth is enabled and both lists are empty, OAuth sign-in is rejected unless `oauth_allow_all_users` is true | +| `oauth_allow_all_users` | bool | Default `false`. If `true`, skip email allowlist for OAuth sign-in (any OAuth user with an email may sign in). `oauth_admin_emails` still controls admin access only | | `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 | @@ -83,7 +85,9 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j url: http://localhost:5173 users: - admin_emails: [] + oauth_admin_emails: [] + oauth_allowed_emails: [] + oauth_allow_all_users: false local_users: [] default_admin: username: admin @@ -116,7 +120,9 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j "url": "http://localhost:5173" }, "users": { - "admin_emails": [], + "oauth_admin_emails": [], + "oauth_allowed_emails": [], + "oauth_allow_all_users": false, "local_users": [] } } diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index ffdfab7..580db9b 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -38,7 +38,9 @@ Environment variables override config file values. Useful for secrets and deploy | Variable | Overrides | Description | |----------|-----------|-------------| -| `ADMIN_EMAILS` | `users.admin_emails` | Comma-separated admin emails (OAuth users) | +| `OAUTH_ADMIN_EMAILS` | `users.oauth_admin_emails` | Comma-separated OAuth admin emails (admin + may sign in) | +| `OAUTH_ALLOWED_EMAILS` | `users.oauth_allowed_emails` | Comma-separated OAuth emails allowed to sign in without admin | +| `OAUTH_ALLOW_ALL_USERS` | `users.oauth_allow_all_users` | `true`/`1`/`yes`/`on` to allow any OAuth user to sign in (ignores email lists for sign-in) | ## Startup logging diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 033c6f2..3e0d4de 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -36,7 +36,7 @@ Configuration is loaded in this order: **environment variables** override **conf url: http://example.com users: - admin_emails: [] + oauth_admin_emails: [] local_users: [] ``` @@ -68,7 +68,7 @@ Configuration is loaded in this order: **environment variables** override **conf "url": "http://example.com" }, "users": { - "admin_emails": [], + "oauth_admin_emails": [], "local_users": [] } } @@ -94,7 +94,7 @@ Configuration is loaded in this order: **environment variables** override **conf export FRONTEND_URL="http://example.com" export CORS_ORIGINS="http://localhost:5173" - export ADMIN_EMAILS="admin@example.com" + export OAUTH_ADMIN_EMAILS="admin@example.com" ``` See [Environment variables](environment-variables.md) for the full reference. @@ -111,4 +111,4 @@ Configuration is loaded in this order: **environment variables** override **conf - **Server** — `root_path`, `max_upload_bytes`, `debug` - **Auth** — `jwt_secret`, `oauth_redirect_url`, `local_auth_enabled`, `providers` (google, github) - **Frontend** — `url`, `cors_origins` -- **Users** — `admin_emails`, `local_users`, `default_admin` +- **Users** — `oauth_admin_emails`, `oauth_allowed_emails`, `oauth_allow_all_users`, `local_users`, `default_admin` diff --git a/docs/features/secure-auth.md b/docs/features/secure-auth.md index 075b53c..65044aa 100644 --- a/docs/features/secure-auth.md +++ b/docs/features/secure-auth.md @@ -23,6 +23,10 @@ You can enable multiple providers; users choose which one to use at login. - **Storage** — Tokens are kept in memory (or localStorage, depending on frontend implementation). No server-side session store. - **Usage** — Send the token as `Authorization: Bearer ` or as `?token=` for GET requests (e.g. preview links). +## OAuth allowlist + +Only addresses in **`oauth_admin_emails`** ∪ **`oauth_allowed_emails`** can complete OAuth sign-in, unless **`oauth_allow_all_users`** is enabled (any OAuth user with an email may sign in; use only in trusted environments). `oauth_admin_emails` grants **admin** access; `oauth_allowed_emails` grants **regular** access (Settings UI remains admin-only). If Google or GitHub is enabled but both lists are empty and allow-all is off, OAuth sign-in is denied (use local auth or add at least one email). The server logs a warning at startup in that case. + ## Admin access -Users listed in `admin_emails` (for OAuth) or with `is_admin: true` (for local users) can access the admin UI to manage auth providers and local users. A `default_admin` user can be bootstrapped on first run when no users exist. +Users listed in `oauth_admin_emails` (for OAuth) or with `is_admin: true` (for local users) can access the admin UI to manage auth providers and local users. A `default_admin` user can be bootstrapped on first run when no users exist. diff --git a/docs/features/simple-admin.md b/docs/features/simple-admin.md index e4394bb..f402762 100644 --- a/docs/features/simple-admin.md +++ b/docs/features/simple-admin.md @@ -15,7 +15,7 @@ No database — configuration is stored in a single YAML or JSON file. The admin ## Admin UI -Admins (users in `admin_emails` or with `is_admin`) can access the Settings page to: +Admins (users in `oauth_admin_emails` or with `is_admin`) can access the Settings page to: - **Auth providers** — Enable/disable Google and GitHub OAuth, set client ID and secret - **Local users** — Add, edit, remove username/password users