package auth import ( "crypto/rand" "encoding/hex" "errors" "fmt" "sync" "time" "github.com/brasse-b/archivum/internal/config" "github.com/brasse-b/archivum/internal/db" "github.com/go-ldap/ldap/v3" "golang.org/x/crypto/bcrypt" ) // Session represents an authenticated user session held in memory. type Session struct { Username string Token string Role string Groups []string // group names (from OIDC claim or local membership) ExpiresAt time.Time } // Manager handles session creation and validation. type Manager struct { cfg *config.Config mu sync.RWMutex sessions map[string]*Session } func NewManager(cfg *config.Config) *Manager { return &Manager{ cfg: cfg, sessions: make(map[string]*Session), } } // UpdateConfig replaces the stored config (used after LDAP config change). func (m *Manager) UpdateConfig(cfg *config.Config) { m.mu.Lock() m.cfg = cfg m.mu.Unlock() } // HashPassword returns a bcrypt hash of the given password. func HashPassword(password string) (string, error) { b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) return string(b), err } // CheckPassword compares a bcrypt hashed password with its possible // plaintext equivalent. Returns nil on success, or an error on failure. func CheckPassword(hashedPassword, password string) error { return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) } // Login authenticates via local DB first, then falls back to LDAP if configured. // Returns the session token, the user's role, and any error. // password is a SHA-256 hash (for local accounts); ldapPassword is plaintext (for LDAP bind). func (m *Manager) Login(database *db.DB, username, password, ldapPassword string) (token, role string, err error) { // Guest / public login — no password required. if username == "guest" { groups, _ := database.GetUserGroupNames("guest") tok, err := m.createSession("guest", "guest", groups) return tok, "guest", err } user, dbErr := database.GetUser(username) if dbErr == nil { // If the account has a local password hash, always authenticate locally. // This ensures admin accounts are never accidentally routed through LDAP, // even if they were also imported from the directory. if user.PassHash != "" { if bcrypt.CompareHashAndPassword([]byte(user.PassHash), []byte(password)) != nil { return "", "", errors.New("invalid credentials") } groups, _ := database.GetUserGroupNames(username) tok, err := m.createSession(username, user.Role, groups) return tok, user.Role, err } // No local password — must be an LDAP-only account. if user.IsLDAP { m.mu.RLock() cfg := m.cfg m.mu.RUnlock() if cfg == nil || cfg.LDAP.Url == "" { return "", "", errors.New("LDAP not configured") } ldapPwd := ldapPassword if ldapPwd == "" { ldapPwd = password } if err := ldapUserBind(cfg, username, ldapPwd); err != nil { return "", "", errors.New("invalid credentials") } groups, _ := database.GetUserGroupNames(username) tok, err := m.createSession(username, user.Role, groups) return tok, user.Role, err } // In DB but no password and not LDAP — refuse. return "", "", errors.New("invalid credentials") } // User not in DB. If LDAP is configured try a bind with the provided password. m.mu.RLock() cfg := m.cfg m.mu.RUnlock() if cfg == nil || cfg.LDAP.Url == "" { return "", "", errors.New("invalid credentials") } ldapPwd := ldapPassword if ldapPwd == "" { ldapPwd = password } if err := ldapUserBind(cfg, username, ldapPwd); err != nil { return "", "", err } tok, err := m.createSession(username, "user", nil) return tok, "user", err } // LoginOIDC establishes a session from a verified OIDC identity. It maps the // group claim to a role (admin group → admin, otherwise user), mirrors the // user and groups into the local DB (for the admin UI and ACL targeting), and // enforces the login allow-list. Returns an error if the account is not // permitted to sign in. func (m *Manager) LoginOIDC(database *db.DB, u *OIDCUser) (token, role string, err error) { m.mu.RLock() cfg := m.cfg m.mu.RUnlock() adminGroup, readerGroup := "Archivum-admin", "Archivum-reader" if cfg != nil { oc := cfg.OIDC oc.Normalize() adminGroup, readerGroup = oc.AdminGroup, oc.ReaderGroup } _ = readerGroup // reader currently maps to the ACL-gated "user" role // Mirror the token's groups locally so ACLs can target them and the admin // UI can list them. for _, g := range u.Groups { _ = database.CreateOrUpdateGroup(g, true) } allowed := database.LoginAllowed(u.Username, u.Groups) role = "user" for _, g := range u.Groups { if g == adminGroup { role = "admin" break } } // Provision / refresh the user record and its group mirror. allow_login is // seeded from the gate result on first insert and preserved afterwards. _ = database.CreateOrUpdateExternalUser(u.Username, role, allowed) if role == "admin" { _ = database.SetUserRole(u.Username, "admin") } _ = database.SyncUserGroups(u.Username, u.Groups) if !allowed { return "", "", errors.New("this account is not permitted to sign in to Archivum") } tok, err := m.createSession(u.Username, role, u.Groups) return tok, role, err } // UserAuthType returns "guest", "ldap", or "local" for the given username. // A user with a non-empty pass_hash is always "local", regardless of is_ldap, // so that the frontend hashes the password before sending it. func UserAuthType(database *db.DB, username string) string { if username == "guest" { return "guest" } user, err := database.GetUser(username) if err != nil { // User not found in DB — assume LDAP so the frontend sends plaintext. return "ldap" } if user.PassHash != "" { return "local" } if user.IsLDAP { return "ldap" } return "local" } // TestLDAP tests an LDAP configuration by performing a service-account bind. func TestLDAP(url string, adminUser, adminPassword string) error { l, err := ldap.DialURL(url) if err != nil { return err } defer l.Close() if adminUser != "" { return l.Bind(adminUser, adminPassword) } return nil } func ldapUserBind(cfg *config.Config, username, password string) error { l, err := ldap.DialURL(cfg.LDAP.Url) if err != nil { return err } defer l.Close() if cfg.LDAP.AdminUser != "" { if err := l.Bind(cfg.LDAP.AdminUser, cfg.LDAP.AdminPass); err != nil { return err } } baseDN := cfg.LDAP.BaseDN escaped := ldap.EscapeFilter(username) // Include cn so OpenLDAP entries of the form cn=username are matched too. filter := fmt.Sprintf("(|(uid=%s)(sAMAccountName=%s)(cn=%s))", escaped, escaped, escaped) searchRequest := ldap.NewSearchRequest( baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, filter, []string{"dn"}, nil, ) sr, err := l.Search(searchRequest) if err != nil { return err } if len(sr.Entries) == 0 { return errors.New("user not found in LDAP") } // Bind to the exact DN of the user discovered return l.Bind(sr.Entries[0].DN, password) } // BrowseLDAP returns users and groups from the LDAP server. func BrowseLDAP(url, baseDN, adminUser, adminPassword string) ([]string, []string, error) { l, err := ldap.DialURL(url) if err != nil { return nil, nil, err } defer l.Close() if adminUser != "" { if err := l.Bind(adminUser, adminPassword); err != nil { return nil, nil, err } } // Search for Users — cover inetOrgPerson (OpenLDAP), person, user (AD), posixAccount userReq := ldap.NewSearchRequest( baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(|(objectClass=inetOrgPerson)(objectClass=person)(objectClass=user)(objectClass=posixAccount))", []string{"uid", "sAMAccountName", "cn"}, nil, ) var users []string if sr, err := l.Search(userReq); err == nil { for _, e := range sr.Entries { if v := e.GetAttributeValue("sAMAccountName"); v != "" { users = append(users, v) } else if v := e.GetAttributeValue("uid"); v != "" { users = append(users, v) } else if v := e.GetAttributeValue("cn"); v != "" { users = append(users, v) } } } // Search for Groups — cover groupOfNames, group (AD), posixGroup, groupOfUniqueNames groupReq := ldap.NewSearchRequest( baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(|(objectClass=groupOfNames)(objectClass=group)(objectClass=posixGroup)(objectClass=groupOfUniqueNames))", []string{"cn"}, nil, ) var groups []string if sr, err := l.Search(groupReq); err == nil { for _, e := range sr.Entries { if v := e.GetAttributeValue("cn"); v != "" { groups = append(groups, v) } } } return users, groups, nil } func (m *Manager) createSession(username, role string, groups []string) (string, error) { token, err := generateToken() if err != nil { return "", err } m.mu.Lock() m.sessions[token] = &Session{ Username: username, Token: token, Role: role, Groups: groups, ExpiresAt: time.Now().Add(24 * time.Hour), } m.mu.Unlock() return token, nil } // Validate looks up a token and returns its session if valid and not expired. func (m *Manager) Validate(token string) (*Session, error) { m.mu.RLock() s, ok := m.sessions[token] m.mu.RUnlock() if !ok { return nil, errors.New("invalid token") } if time.Now().After(s.ExpiresAt) { m.mu.Lock() delete(m.sessions, token) m.mu.Unlock() return nil, errors.New("token expired") } return s, nil } // Logout removes a session. func (m *Manager) Logout(token string) { m.mu.Lock() delete(m.sessions, token) m.mu.Unlock() } func generateToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil }