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 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. func (m *Manager) Login(database *db.DB, username, password string) (token, role string, err error) { // Try local account first. user, dbErr := database.GetUser(username) if dbErr == nil { if bcrypt.CompareHashAndPassword([]byte(user.PassHash), []byte(password)) != nil { return "", "", errors.New("invalid credentials") } tok, err := m.createSession(username, user.Role) return tok, user.Role, err } // If not found locally and LDAP is configured, try LDAP. m.mu.RLock() cfg := m.cfg m.mu.RUnlock() if cfg == nil || cfg.LDAP.Url == "" { return "", "", errors.New("invalid credentials") } if err := ldapUserBind(cfg, username, password); err != nil { return "", "", err } tok, err := m.createSession(username, "user") return tok, "user", err } // 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 } } searchRequest := ldap.NewSearchRequest( "", // Search from root or a specific BaseDN if needed. Assuming root here since URL can contain it, or we rely on LDAP configured properly ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, fmt.Sprintf("(|(uid=%s)(sAMAccountName=%s))", ldap.EscapeFilter(username), ldap.EscapeFilter(username)), []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 string, 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 userReq := ldap.NewSearchRequest( "", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(|(objectClass=person)(objectClass=user))", []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 groupReq := ldap.NewSearchRequest( "", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(|(objectClass=groupOfNames)(objectClass=group))", []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) (string, error) { token, err := generateToken() if err != nil { return "", err } m.mu.Lock() m.sessions[token] = &Session{ Username: username, Token: token, Role: role, 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 }