309 lines
13 KiB
Go
309 lines
13 KiB
Go
package settings
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
ldap "github.com/go-ldap/ldap/v3"
|
|
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
|
"github.com/gtsteffaniak/go-logger/logger"
|
|
)
|
|
|
|
// userAgentTransport wraps an http.RoundTripper to add a User-Agent header
|
|
type userAgentTransport struct {
|
|
base http.RoundTripper
|
|
userAgent string
|
|
}
|
|
|
|
// RoundTrip implements the http.RoundTripper interface
|
|
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
// Clone the request to avoid modifying the original
|
|
reqCopy := req.Clone(req.Context())
|
|
// Set User-Agent header if not already set
|
|
if reqCopy.Header.Get("User-Agent") == "" {
|
|
reqCopy.Header.Set("User-Agent", t.userAgent)
|
|
}
|
|
return t.base.RoundTrip(reqCopy)
|
|
}
|
|
|
|
// AuthCommon contains fields shared across multiple authentication methods
|
|
type AuthCommon struct {
|
|
Enabled bool `json:"enabled"` // whether to enable this authentication method.
|
|
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges
|
|
UserGroups []string `json:"userGroups"` // if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
|
|
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
|
|
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
|
|
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification (insecure, for testing only)
|
|
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
|
CreateUser bool `json:"createUser"` // deprecated: always true for supported authentication methods
|
|
}
|
|
|
|
type Auth struct {
|
|
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
|
|
Methods LoginMethods `json:"methods"`
|
|
Key string `json:"key"` // secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
|
|
AdminUsername string `json:"adminUsername"` // secret: the username of the admin user. If not set, the default is "admin".
|
|
AdminPassword string `json:"adminPassword"` // secret: the password of the admin user. If not set, the default is "admin".
|
|
TotpSecret string `json:"totpSecret"` // secret: secret used to encrypt TOTP secrets
|
|
AuthMethods []string `json:"-"`
|
|
}
|
|
|
|
type LoginMethods struct {
|
|
ProxyAuth ProxyAuthConfig `json:"proxy" validate:"omitempty"`
|
|
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
|
|
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
|
|
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
|
|
LdapAuth LdapConfig `json:"ldap" validate:"omitempty"`
|
|
JwtAuth JwtAuthConfig `json:"jwt" validate:"omitempty"`
|
|
PasskeyAuth PasskeyAuthConfig `json:"passkey" validate:"omitempty"`
|
|
}
|
|
|
|
type PasswordAuthConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
MinLength int `json:"minLength" validate:"omitempty"` // minimum pasword length required, default is 5.
|
|
Signup bool `json:"signup" validate:"omitempty"` // allow signups on login page if enabled -- not secure.
|
|
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"` // recaptcha config, only used if signup is enabled
|
|
EnforcedOtp bool `json:"enforcedOtp"` // if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
|
|
}
|
|
|
|
type ProxyAuthConfig struct {
|
|
AuthCommon `json:",inline"`
|
|
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
|
|
}
|
|
|
|
type Recaptcha struct {
|
|
Host string `json:"host" validate:"required"`
|
|
Key string `json:"key" validate:"required"`
|
|
Secret string `json:"secret" validate:"required"`
|
|
}
|
|
|
|
// OpenID OAuth2.0
|
|
type OidcConfig struct {
|
|
AuthCommon `json:",inline"`
|
|
ClientID string `json:"clientId"` // secret: client id of the OIDC application
|
|
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
|
|
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
|
|
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
|
|
Provider *oidc.Provider `json:"-"` // OIDC provider
|
|
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
|
|
}
|
|
|
|
type LdapConfig struct {
|
|
AuthCommon `json:",inline"`
|
|
Server string `json:"server"` // scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
|
|
BaseDN string `json:"baseDN"` // LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
|
|
UserDN string `json:"userDN"` // Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
|
|
UserPassword string `json:"userPassword"` // Bind password for service account
|
|
UserFilter string `json:"userFilter"` // Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
|
|
Port int `json:"-"` // derived from server
|
|
Scheme string `json:"-"` // derived from server
|
|
Host string `json:"-"` // derived from server
|
|
}
|
|
|
|
// JwtAuthConfig configures external JWT token authentication
|
|
// Similar to Grafana's JWT auth: verifies HS* tokens with a shared secret, or RS*/ES* with a PEM public key (or certificate)
|
|
// The query parameter is hardcoded to "jwt" (e.g. ?jwt=<token>)
|
|
type JwtAuthConfig struct {
|
|
AuthCommon `json:",inline"`
|
|
Header string `json:"header"` // HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
|
|
Secret string `json:"secret"` // secret: Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
|
|
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
|
|
}
|
|
|
|
// PasskeyAuthConfig configures WebAuthn / Passkey authentication as a second factor.
|
|
type PasskeyAuthConfig struct {
|
|
Enabled bool `json:"enabled"` // whether to enable passkey MFA
|
|
RPDisplayName string `json:"rpDisplayName"` // the Relying Party display name (defaults to frontend name)
|
|
RPID string `json:"rpId"` // the Relying Party ID; if empty, auto-derived from base URL host
|
|
RPOrigins []string `json:"rpOrigins"` // allowed origins; if empty, auto-derived from base URL
|
|
LoginButtonText string `json:"loginButtonText"` // custom text for the passkey login button
|
|
}
|
|
|
|
// ValidateLdapAuth checks LDAP config and sets defaults. Call when LDAP is enabled.
|
|
func ValidateLdapAuth() error {
|
|
ldapCfg := &Config.Auth.Methods.LdapAuth
|
|
if ldapCfg.Server == "" {
|
|
return fmt.Errorf("LDAP server is required when LDAP is enabled")
|
|
}
|
|
scheme, host, port, err := parseLdapServer(ldapCfg.Server)
|
|
if err != nil {
|
|
return fmt.Errorf("LDAP server is invalid: %w", err)
|
|
}
|
|
ldapCfg.Server = fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
|
ldapCfg.Scheme = scheme
|
|
ldapCfg.Host = host
|
|
ldapCfg.Port = port
|
|
|
|
if ldapCfg.BaseDN == "" {
|
|
return fmt.Errorf("LDAP baseDN is required when LDAP is enabled")
|
|
}
|
|
if ldapCfg.UserDN == "" {
|
|
return fmt.Errorf("LDAP userDN (bind DN) is required when LDAP is enabled")
|
|
}
|
|
if ldapCfg.UserFilter == "" {
|
|
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
|
|
}
|
|
if err := verifyLdapConnection(); err != nil {
|
|
logger.Fatalf("LDAP connection check failed: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
|
|
func validateOidcAuth() error {
|
|
oidcCfg := &Config.Auth.Methods.OidcAuth // Use a pointer to modify the original config
|
|
if oidcCfg.GroupsClaim == "" {
|
|
oidcCfg.GroupsClaim = "groups"
|
|
}
|
|
if oidcCfg.UserIdentifier == "" {
|
|
oidcCfg.UserIdentifier = "preferred_username"
|
|
}
|
|
if oidcCfg.Scopes == "" {
|
|
oidcCfg.Scopes = "openid email profile"
|
|
}
|
|
|
|
if !oidcCfg.Enabled {
|
|
return errors.New("OIDC is not enabled")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
// Create a custom HTTP client with proper User-Agent to avoid being blocked by bot protection
|
|
transport := &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment, // Respect HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables
|
|
}
|
|
|
|
// If disableVerifyTLS is true, disable TLS verification
|
|
if oidcCfg.DisableVerifyTLS {
|
|
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
|
logger.Warning("OIDC TLS verification is disabled.")
|
|
}
|
|
|
|
// Create custom client with User-Agent header to bypass bot protection (like Cloudflare Bot Fight Mode)
|
|
customClient := &http.Client{
|
|
Transport: &userAgentTransport{
|
|
base: transport,
|
|
userAgent: fmt.Sprintf("FileBrowser Quantum - %s (OIDC Client)", version.Version),
|
|
},
|
|
}
|
|
|
|
// Use oidc.ClientContext to pass the custom client to the OIDC library
|
|
ctx = oidc.ClientContext(ctx, customClient)
|
|
|
|
provider, err := oidc.NewProvider(ctx, oidcCfg.IssuerUrl)
|
|
if err != nil {
|
|
return fmt.Errorf("url '%v' failed to create OIDC provider: %w", oidcCfg.IssuerUrl, err)
|
|
}
|
|
oidcCfg.Provider = provider
|
|
oidcCfg.Verifier = provider.Verifier(&oidc.Config{ClientID: oidcCfg.ClientID})
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseLdapServer(server string) (scheme, host string, port int, err error) {
|
|
parts := strings.Split(server, "://")
|
|
if len(parts) != 2 {
|
|
return "", "", 0, fmt.Errorf("invalid LDAP server: %s", server)
|
|
}
|
|
scheme = strings.ToLower(parts[0])
|
|
if scheme != "ldap" && scheme != "ldaps" {
|
|
return "", "", 0, fmt.Errorf("invalid LDAP scheme: %s", scheme)
|
|
}
|
|
hostParts := strings.Split(parts[1], ":")
|
|
host = strings.TrimSuffix(hostParts[0], "/")
|
|
if len(hostParts) > 1 && hostParts[1] != "" {
|
|
port, err = strconv.Atoi(strings.TrimSuffix(hostParts[1], "/"))
|
|
if err != nil || port == 0 {
|
|
return "", "", 0, fmt.Errorf("invalid LDAP port: %s", hostParts[1])
|
|
}
|
|
} else {
|
|
if scheme == "ldaps" {
|
|
port = 636
|
|
} else {
|
|
port = 389
|
|
}
|
|
}
|
|
return scheme, host, port, nil
|
|
}
|
|
|
|
// VerifyLdapConnection tests that the LDAP server is reachable, can bind (if credentials set), and responds to a search.
|
|
func verifyLdapConnection() error {
|
|
if Config.Auth.Methods.LdapAuth.Server == "" {
|
|
return fmt.Errorf("LDAP server is required when LDAP is enabled")
|
|
}
|
|
if Config.Auth.Methods.LdapAuth.BaseDN == "" {
|
|
return fmt.Errorf("LDAP baseDN is required for connection verification")
|
|
}
|
|
var opts []ldap.DialOpt
|
|
scheme := Config.Auth.Methods.LdapAuth.Scheme
|
|
host := Config.Auth.Methods.LdapAuth.Host
|
|
port := Config.Auth.Methods.LdapAuth.Port
|
|
if Config.Auth.Methods.LdapAuth.DisableVerifyTLS && scheme == "ldaps" {
|
|
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}))
|
|
}
|
|
fullURL := fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
|
conn, err := ldap.DialURL(fullURL, opts...)
|
|
if err != nil {
|
|
return fmt.Errorf("LDAP connection failed (is the LDAP outpost running at %s? use port 389 for LDAP, 636 for LDAPS; this is not the HTTP port): %w", fullURL, err)
|
|
}
|
|
defer conn.Close()
|
|
if Config.Auth.Methods.LdapAuth.UserDN != "" && Config.Auth.Methods.LdapAuth.UserPassword != "" {
|
|
if err = conn.Bind(Config.Auth.Methods.LdapAuth.UserDN, Config.Auth.Methods.LdapAuth.UserPassword); err != nil {
|
|
return fmt.Errorf("LDAP bind failed (check userDN and userPassword): %w", err)
|
|
}
|
|
defer func() { _ = conn.Unbind() }()
|
|
}
|
|
// Dummy search to verify the server responds to LDAP search (same path that fails at login if misconfigured).
|
|
searchRequest := ldap.NewSearchRequest(
|
|
Config.Auth.Methods.LdapAuth.BaseDN,
|
|
ldap.ScopeBaseObject,
|
|
ldap.NeverDerefAliases,
|
|
0,
|
|
0,
|
|
false,
|
|
"(objectClass=*)",
|
|
[]string{"1.1"}, // no attributes
|
|
nil,
|
|
)
|
|
_, err = conn.Search(searchRequest)
|
|
if err != nil {
|
|
return fmt.Errorf("LDAP search test failed (server may require StartTLS or bind before search): %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateJwtAuth checks JWT config and sets defaults. Call when JWT auth is enabled.
|
|
func ValidateJwtAuth() error {
|
|
jwtCfg := &Config.Auth.Methods.JwtAuth
|
|
if jwtCfg.Secret == "" {
|
|
return fmt.Errorf("JWT secret is required when JWT auth is enabled")
|
|
}
|
|
if jwtCfg.Header == "" {
|
|
jwtCfg.Header = "X-JWT-Assertion"
|
|
}
|
|
if jwtCfg.Algorithm == "" {
|
|
jwtCfg.Algorithm = "HS256"
|
|
}
|
|
if jwtCfg.GroupsClaim == "" {
|
|
jwtCfg.GroupsClaim = "groups"
|
|
}
|
|
if jwtCfg.UserIdentifier == "" {
|
|
jwtCfg.UserIdentifier = "sub"
|
|
}
|
|
// Validate algorithm
|
|
validAlgos := map[string]bool{
|
|
"HS256": true, "HS384": true, "HS512": true,
|
|
"RS256": true, "RS384": true, "RS512": true,
|
|
"ES256": true, "ES384": true, "ES512": true,
|
|
}
|
|
if !validAlgos[jwtCfg.Algorithm] {
|
|
return fmt.Errorf("unsupported JWT algorithm: %s. Supported: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512", jwtCfg.Algorithm)
|
|
}
|
|
return nil
|
|
}
|