feat: add LDAP support and guest user functionality

- Introduced BaseDN configuration for LDAP in the config.
- Enhanced User model to include IsLDAP field.
- Updated database queries to handle LDAP users.
- Implemented GraphQL queries for LDAP browsing and user authentication type.
- Added ACL management for users and groups, including guest user permissions.
- Updated frontend to support LDAP login and guest user login.
- Improved error handling and user feedback in login and ACL management.
This commit is contained in:
2026-04-15 00:42:40 +02:00
parent 61f33d350a
commit d571f156a6
9 changed files with 710 additions and 93 deletions

View File

@@ -75,6 +75,10 @@ func (s *Server) initRuntime(cfg *config.Config) {
log.Printf("[db] failed to open at %s: %v", cfg.DBPath, err)
} else {
d = database
// Ensure guest user exists (handles upgrades from older versions).
if err := d.EnsureGuestUser(); err != nil {
log.Printf("[db] warning: could not ensure guest user: %v", err)
}
log.Printf("[db] opened at %s", cfg.DBPath)
}
@@ -157,6 +161,9 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
s.handleTestLdapConnection(w, req)
// ── Auth ─────────────────────────────────────────────────────────────────
case strings.Contains(q, "userAuthType"):
s.handleUserAuthType(w, req)
case strings.Contains(q, "login"):
s.handleLogin(w, req)
@@ -202,6 +209,9 @@ func (s *Server) dispatchAuthenticated(
case strings.Contains(q, "changePassword"):
s.handleChangePassword(w, req, sess)
case strings.Contains(q, "ldapBrowse"):
s.handleLdapBrowse(w, req, sess)
case strings.Contains(q, "users"):
s.handleUsers(w, sess)
@@ -232,12 +242,12 @@ func (s *Server) dispatchAuthenticated(
case strings.Contains(q, "moveDocument"):
s.handleMoveDocument(w, req, sess)
case strings.Contains(q, "ldapBrowse"):
s.handleLdapBrowse(w, req, sess)
case strings.Contains(q, "importLdapSubject"):
s.handleImportLdapSubject(w, req, sess)
case strings.Contains(q, "aclSubjects"):
s.handleAclSubjects(w, sess)
case strings.Contains(q, "acl(") || strings.Contains(q, "acl "):
s.handleAcl(w, req, sess)
@@ -404,6 +414,17 @@ func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, sess *au
log.Printf("[document] user %q is opening document %q", sess.Username, slug)
// Guest users may only read documents they have been given access to.
s.mu.RLock()
database := s.database
s.mu.RUnlock()
if sess.Role == "guest" && database != nil {
if !database.CanUserReadPath(sess.Username, slug) && !database.CanUserViewPath(sess.Username, slug) {
writeGQLError(w, "UNAUTHORIZED")
return
}
}
content, err := store.Read(slug)
if err != nil {
log.Printf("[storage] read %q: %v", slug, err)
@@ -437,8 +458,18 @@ func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, sess *au
log.Printf("[document] user %q listed documents (found %d documents)", sess.Username, len(slugs))
s.mu.RLock()
database := s.database
s.mu.RUnlock()
docs := make([]map[string]string, 0, len(slugs))
for _, slug := range slugs {
// Guest users may only see documents they have explicit read or view access to.
if sess.Role == "guest" && database != nil {
if !database.CanUserReadPath(sess.Username, slug) && !database.CanUserViewPath(sess.Username, slug) {
continue
}
}
content, err := store.Read(slug)
title := slug
if err == nil {
@@ -573,6 +604,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) {
if password == "" {
password, _ = req.Variables["password"].(string)
}
// ldapPassword carries the plaintext password sent by the frontend for LDAP users.
ldapPassword, _ := req.Variables["ldapPassword"].(string)
s.mu.RLock()
database := s.database
@@ -584,7 +617,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) {
return
}
token, role, err := mgr.Login(database, username, password)
token, role, err := mgr.Login(database, username, password, ldapPassword)
if err != nil {
log.Printf("[auth] login failed for %q: %v", username, err)
writeGQLError(w, "invalid credentials")
@@ -599,6 +632,27 @@ func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) {
})
}
func (s *Server) handleUserAuthType(w http.ResponseWriter, req gqlRequest) {
username := strVal(req.Variables, "username")
if username == "" {
username, _ = req.Variables["u"].(string)
}
s.mu.RLock()
database := s.database
s.mu.RUnlock()
if database == nil {
writeGQLError(w, "server not initialised")
return
}
authType := auth.UserAuthType(database, username)
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{"userAuthType": authType},
})
}
func (s *Server) handleLogout(w http.ResponseWriter, sess *auth.Session) {
if sess != nil {
s.mu.RLock()
@@ -655,6 +709,59 @@ func (s *Server) handleUsers(w http.ResponseWriter, sess *auth.Session) {
})
}
func (s *Server) handleAclSubjects(w http.ResponseWriter, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
s.mu.RLock()
database := s.database
s.mu.RUnlock()
if database == nil {
writeGQLError(w, "database not initialised")
return
}
users, err := database.ListUsers()
if err != nil {
writeGQLError(w, fmt.Sprintf("failed to list users: %v", err))
return
}
groups, err := database.ListGroups()
if err != nil {
writeGQLError(w, fmt.Sprintf("failed to list groups: %v", err))
return
}
userList := make([]map[string]interface{}, 0, len(users))
for _, u := range users {
userList = append(userList, map[string]interface{}{
"id": u.ID,
"name": u.Username,
"isLdap": u.IsLDAP,
})
}
groupList := make([]map[string]interface{}, 0, len(groups))
for _, g := range groups {
groupList = append(groupList, map[string]interface{}{
"id": g.ID,
"name": g.Name,
"isLdap": g.IsLDAP,
})
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"aclSubjects": map[string]interface{}{
"users": userList,
"groups": groupList,
},
},
})
}
func (s *Server) handleCreateUser(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil {
log.Printf("[unauth] session is nil")
@@ -814,6 +921,7 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
s.mu.RLock()
cfg := s.cfg
mgr := s.authMgr
s.mu.RUnlock()
if cfg == nil {
@@ -828,10 +936,15 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
newCfg := *cfg // copy
if hasInput && input != nil {
newPass := strVal(input, "adminPass")
if newPass == "" {
newPass = cfg.LDAP.AdminPass // preserve existing password if none provided
}
newCfg.LDAP = config.LDAPConfig{
Url: strVal(input, "url"),
BaseDN: strVal(input, "baseDN"),
AdminUser: strVal(input, "adminUser"),
AdminPass: strVal(input, "adminPass"),
AdminPass: newPass,
}
} else {
// Disable LDAP
@@ -843,7 +956,15 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
return
}
s.initRuntime(&newCfg)
// Update the config in-place without reinitialising the runtime.
// This preserves all active sessions while applying the new LDAP settings.
s.mu.Lock()
s.cfg = &newCfg
s.mu.Unlock()
if mgr != nil {
mgr.UpdateConfig(&newCfg)
}
log.Printf("[admin] LDAP config updated by %s", sess.Username)
writeJSON(w, `{"data":{"updateLdapConfig":true}}`)
}
@@ -871,10 +992,11 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
return
}
var ldap map[string]interface{}
var ldapCfg map[string]interface{}
if cfg.LDAP.Url != "" {
ldap = map[string]interface{}{
ldapCfg = map[string]interface{}{
"url": cfg.LDAP.Url,
"baseDN": cfg.LDAP.BaseDN,
"adminUser": cfg.LDAP.AdminUser,
}
}
@@ -883,7 +1005,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
"data": map[string]interface{}{
"config": map[string]interface{}{
"storagePath": cfg.StoragePath,
"ldap": ldap,
"ldap": ldapCfg,
},
},
})
@@ -1043,6 +1165,11 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
return
}
// Ensure the built-in guest account exists (no password, role="guest").
if err := database.EnsureGuestUser(); err != nil {
log.Printf("[setup] warning: could not create guest user: %v", err)
}
log.Printf("[setup] admin user %q created", adminUser)
if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil {
@@ -1255,18 +1382,20 @@ func (s *Server) handleLdapBrowse(w http.ResponseWriter, req gqlRequest, sess *a
url_ := strVal(req.Variables, "url")
adminUser := strVal(req.Variables, "adminUser")
adminPass := strVal(req.Variables, "adminPass")
baseDN := strVal(req.Variables, "baseDN")
if url_ == "" {
s.mu.RLock()
if s.cfg != nil && s.cfg.LDAP.Url != "" {
url_ = s.cfg.LDAP.Url
baseDN = s.cfg.LDAP.BaseDN
adminUser = s.cfg.LDAP.AdminUser
adminPass = s.cfg.LDAP.AdminPass
}
s.mu.RUnlock()
}
users, groups, err := auth.BrowseLDAP(url_, adminUser, adminPass)
users, groups, err := auth.BrowseLDAP(url_, baseDN, adminUser, adminPass)
if err != nil {
writeGQLError(w, fmt.Sprintf("LDAP traverse error: %v", err))
return
@@ -1724,6 +1853,7 @@ func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) {
s.mu.RLock()
store := s.store
database := s.database
s.mu.RUnlock()
if store == nil {
@@ -1738,6 +1868,17 @@ func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) {
return
}
// Guest users may only see folders they have been granted view access to.
if sess.Role == "guest" && database != nil {
visible := folders[:0]
for _, f := range folders {
if database.CanUserViewPath(sess.Username, f) {
visible = append(visible, f)
}
}
folders = visible
}
log.Printf("[folders] user %q listed folders (found %d folders)", sess.Username, len(folders))
writeJSONObj(w, map[string]interface{}{