From d571f156a633bb040b1ae037e69f0778ace8c948 Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Wed, 15 Apr 2026 00:42:40 +0200 Subject: [PATCH] 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. --- ARCHITECTURE.md | 126 +++++++++++----- backend/internal/auth/auth.go | 102 ++++++++++--- backend/internal/config/config.go | 1 + backend/internal/db/db.go | 100 ++++++++++++- backend/internal/graph/schema.graphql | 25 +++- backend/internal/graph/server.go | 161 ++++++++++++++++++-- frontend/src/stores/app.ts | 44 +++++- frontend/src/views/AdminView.vue | 208 ++++++++++++++++++++++++-- frontend/src/views/LoginView.vue | 36 ++++- 9 files changed, 710 insertions(+), 93 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 643c226..78cfc1d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -139,18 +139,56 @@ Backenden använder **inte** gqlgen-genererad kod i produktion. `server.go` impl ### Autentisering och sessioner ``` -Login-flöde: - 1. Klient: SHA-256(username:password) → skickas som "password" - 2. Server: bcrypt.CompareHashAndPassword(stored_hash, received_digest) - 3. Vid match: slumpmässig hex-token genereras och sparas i minnet - 4. Token returneras till klient, lagras i localStorage - 5. Alla efterföljande requests: Authorization: Bearer +Login-flöde (lokalt konto): + 1. Frontend: kontrollerar authType (query userAuthType) → "local" + 2. Frontend: SHA-256(username:password) → skickas som "password" + 3. Backend: bcrypt.CompareHashAndPassword(stored_hash, received_digest) + 4. Vid match: slumpmässig hex-token genereras och sparas i minnet + 5. Token returneras till klient, lagras i localStorage + +Login-flöde (LDAP-konto): + 1. Frontend: kontrollerar authType → "ldap" + 2. Frontend: skickar klartextlösenord i fältet "ldapPassword" (EJ hashat) + 3. Backend: hämtar användarens DN via LDAP-sökning (baseDN + filter) + 4. Backend: binder till LDAP med DN + klartextlösenord (ldapBind) + 5. Vid lyckad bind: session skapas med roll "user" + +Login-flöde (gäst): + 1. Frontend: klickar "Continue as Guest" → skickar username="guest", password="" + 2. Backend: identifierar gäst, skapar session utan lösenordskontroll, roll "guest" + 3. Gäst ser bara dokument/mappar som admin explicit gett tillåtelse via ACL Session-storage: in-memory map[token]*Session (försvinner vid omstart) -Session-livstid: 7 dagar (konfigurerat i auth.go) +Session-livstid: 24 timmar (konfigurerat i auth.go) ``` -**Viktigt:** Sessions lagras enbart i minnet. Vid server-omstart måste alla klienter logga in igen. +**Viktigt:** Sessions lagras enbart i minnet. Vid server-omstart måste alla klienter logga in igen. LDAP-konfigurationsändringar (via admin-panelen) nollställer **inte** sessioner — befintliga sessioner är fortfarande giltiga. + +### Gästanvändare + +En inbyggd `guest`-användare skapas automatiskt vid installation och vid serverstart (migrations-safe). Gästen: +- Kräver inget lösenord +- Har roll `"guest"` (skild från `"user"` och `"admin"`) +- Kan som standard inte se något +- Admin ger tillgång per dokument eller mapp via ACL-panelen + +### Åtkomstkontroll (ACL) + +ACL-poster lagras i SQLite-tabellen `acl`. Varje post kopplar en sökväg (slug eller mappnamn) till ett subjekt (användare eller grupp) och sju boolean-flaggor: + +| Flagga | Innebär | +|------------|----------------------------------------| +| canSearch | Syns i sökning/listning | +| canView | Syns i mapplistning | +| canRead | Kan öppna och läsa innehållet | +| canEdit | Kan redigera och spara | +| canCreate | Kan skapa nya dokument i mappen | +| canDelete | Kan radera | +| canMove | Kan flytta/byta namn | + +Sökvägar är hierarkiska: om admin ger gästen tillgång till mappen `docs` räcker det för alla dokument under `docs/`. + +Enforcement sker i `handleDocuments` och `handleFolders` — gästanvändare filtreras mot ACL. Admin-användare kringgår alltid ACL-kontroller. ### Setup-detektion — `needsSetup` @@ -179,7 +217,11 @@ resolveDBPath(configPath): - `db_path` — sökväg till SQLite-fil - `jwt_secret` — används för session-token generation (ej JWT i klassisk mening) - `listen_addr` — TCP-adress att lyssna på, t.ex. `:4000` -- `ldap` — valfri LDAP-konfiguration +- `ldap` — valfri LDAP-konfiguration: + - `url` — LDAP-server URL, t.ex. `ldap://openldap:389` + - `base_dn` — rot-DN att söka ifrån, t.ex. `dc=example,dc=com` + - `admin_user` — tjänstekonto DN för uppslag (valfritt) + - `admin_pass` — tjänstekontoets lösenord (valfritt) Om filen saknas returnerar `Load()` `ErrRequireSetup` — backenden startar i setup-läge. @@ -238,28 +280,37 @@ Ordningen är viktig: om DB-skapandet eller user-insert misslyckas sparas **inge ### 4.3 Inloggningsflöde ``` -Användaren fyller i användarnamn + lösenord +Användaren fyller i användarnamn + lösenord (eller klickar "Continue as Guest") │ ▼ app.login(username, password) │ - ├─ SHA-256(lowercase(username):password) → hashed + ├─ query userAuthType(username) → "local" | "ldap" | "guest" │ - └─ POST /graphql mutation Login($u, $p: hashed) + ├─ authType == "local": + │ SHA-256(lowercase(username):password) → hashed + │ → POST /graphql mutation Login(u, p: hashed) + │ + ├─ authType == "ldap": + │ → POST /graphql mutation Login(u, p: "", ldapPassword: plaintext) + │ + └─ authType == "guest": + → POST /graphql mutation Login(u: "guest", p: "") │ ▼ handleLogin (server.go) │ - ├─ db.GetUser(username) → lokal SQLite - │ ├─ hittad: bcrypt.Compare(stored, hashed) → OK/fel - │ └─ ej hittad + LDAP konfigurerat → ldapBind test + ├─ "guest": returnerar token utan lösenordskontroll │ - ├─ Skapar slumpmässig session-token - └─ Returnerar token + ├─ "ldap" (is_ldap=1 i DB): ldapBind(username, ldapPassword) + │ → OK: session med roll "user" │ - ▼ - Klient sparar token i localStorage - AppLayout renderas + └─ "local" (is_ldap=0): bcrypt.Compare(stored, hashed) + → OK: session med roll baserad på DB-värde + +Gäst-session: + - Roll "guest" — se ACL-enforcement i handleDocuments / handleFolders + - Kan bara se dokument/mappar som admin explicit beviljat via ACL ``` ### 4.4 Spara dokument @@ -366,30 +417,37 @@ Archivum/ **GraphQL-schema (urval):** ```graphql type Query { - systemStatus: SystemStatus! # OK | REQUIRE_SETUP - serverDirectories(path: String): [ServerDirectory!]! # Listar mappar (inkl. isGitRepo) + systemStatus: SystemStatus! + userAuthType(username: String!): String! # "local" | "ldap" | "guest" documents(prefix: String): [DocumentMeta!]! document(slug: String!): Document - folders: [String!]! # Listar alla mappvägar i repositoriet + folders: [String!]! history(slug: String!): [CommitEntry!]! diff(slug: String!, fromHash: String!, toHash: String!): String! -} - -type ServerDirectory { - name: String! - path: String! - isGitRepo: Boolean! - hasDocuments: Boolean! + acl(path: String!): [ACLEntry!]! + aclSubjects: ACLSubjects! # Användare + grupper för ACL-picker + ldapBrowse(url: String, baseDN: String, adminUser: String, adminPass: String): LDAPTree! } type Mutation { - setup(input: SetupInput!): Boolean! - login(username: String!, password: String!): String! # returnerar session-token + login(username: String!, password: String!, ldapPassword: String): String! logout: Boolean! + setup(input: SetupInput!): Boolean! saveDocument(input: SaveDocumentInput!): Document! deleteDocument(slug: String!): Boolean! - createFolder(path: String!): Boolean! # Skapar en ny mapp (katalog) - moveDocument(oldSlug: String!, newSlug: String!): Boolean! # Flyttar ett dokument till en annan plats + updateLdapConfig(input: LDAPInput): Boolean! + importLdapSubject(type: String!, name: String!): Boolean! + setAcl(input: ACLInput!): Boolean! + removeAcl(id: Int!): Boolean! + createFolder(path: String!): Boolean! + moveDocument(oldSlug: String!, newSlug: String!): Boolean! +} + +input LDAPInput { + url: String! + baseDN: String + adminUser: String! + adminPass: String # Utelämnas → befintligt lösenord behålls } ``` diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index ed2f209..5eb8ca4 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -57,18 +57,49 @@ func CheckPassword(hashedPassword, password string) error { // 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 +// 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 login — no password required. + if username == "guest" { + tok, err := m.createSession("guest", "guest") + return tok, "guest", err } - // If not found locally and LDAP is configured, try LDAP. + 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") + } + tok, err := m.createSession(username, user.Role) + 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") + } + tok, err := m.createSession(username, user.Role) + 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() @@ -77,7 +108,11 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role return "", "", errors.New("invalid credentials") } - if err := ldapUserBind(cfg, username, password); err != nil { + ldapPwd := ldapPassword + if ldapPwd == "" { + ldapPwd = password + } + if err := ldapUserBind(cfg, username, ldapPwd); err != nil { return "", "", err } @@ -85,6 +120,27 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role return tok, "user", 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) @@ -111,10 +167,14 @@ func ldapUserBind(cfg *config.Config, username, password string) error { } } + 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( - "", // Search from root or a specific BaseDN if needed. Assuming root here since URL can contain it, or we rely on LDAP configured properly + baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(|(uid=%s)(sAMAccountName=%s))", ldap.EscapeFilter(username), ldap.EscapeFilter(username)), + filter, []string{"dn"}, nil, ) @@ -132,7 +192,7 @@ func ldapUserBind(cfg *config.Config, username, password string) error { } // BrowseLDAP returns users and groups from the LDAP server. -func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string, error) { +func BrowseLDAP(url, baseDN, adminUser, adminPassword string) ([]string, []string, error) { l, err := ldap.DialURL(url) if err != nil { return nil, nil, err @@ -145,10 +205,11 @@ func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string } } - // Search for Users + // Search for Users — cover inetOrgPerson (OpenLDAP), person, user (AD), posixAccount userReq := ldap.NewSearchRequest( - "", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - "(|(objectClass=person)(objectClass=user))", + baseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + "(|(objectClass=inetOrgPerson)(objectClass=person)(objectClass=user)(objectClass=posixAccount))", []string{"uid", "sAMAccountName", "cn"}, nil, ) @@ -165,10 +226,11 @@ func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string } } - // Search for Groups + // Search for Groups — cover groupOfNames, group (AD), posixGroup, groupOfUniqueNames groupReq := ldap.NewSearchRequest( - "", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - "(|(objectClass=groupOfNames)(objectClass=group))", + baseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + "(|(objectClass=groupOfNames)(objectClass=group)(objectClass=posixGroup)(objectClass=groupOfUniqueNames))", []string{"cn"}, nil, ) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8b08ff6..53fa585 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { type LDAPConfig struct { Url string `json:"url"` + BaseDN string `json:"base_dn"` AdminUser string `json:"admin_user"` AdminPass string `json:"admin_pass"` } diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index e9704c0..b905b88 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -18,6 +18,7 @@ type User struct { Username string PassHash string Role string + IsLDAP bool CreatedAt time.Time } @@ -107,12 +108,12 @@ func (d *DB) CreateUser(username, passHash, role string) error { // GetUser fetches a single user by username. func (d *DB) GetUser(username string) (*User, error) { row := d.sql.QueryRow( - `SELECT id, username, pass_hash, role, created_at FROM users WHERE username = ?`, + `SELECT id, username, pass_hash, role, is_ldap, created_at FROM users WHERE username = ?`, username, ) u := &User{} var createdAt string - err := row.Scan(&u.ID, &u.Username, &u.PassHash, &u.Role, &createdAt) + err := row.Scan(&u.ID, &u.Username, &u.PassHash, &u.Role, &u.IsLDAP, &createdAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } @@ -126,7 +127,7 @@ func (d *DB) GetUser(username string) (*User, error) { // ListUsers returns all users ordered by id. func (d *DB) ListUsers() ([]*User, error) { rows, err := d.sql.Query( - `SELECT id, username, role, created_at FROM users ORDER BY id`, + `SELECT id, username, role, is_ldap, created_at FROM users ORDER BY id`, ) if err != nil { return nil, err @@ -137,7 +138,7 @@ func (d *DB) ListUsers() ([]*User, error) { for rows.Next() { u := &User{} var createdAt string - if err := rows.Scan(&u.ID, &u.Username, &u.Role, &createdAt); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.IsLDAP, &createdAt); err != nil { return nil, err } u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) @@ -185,11 +186,14 @@ func (d *DB) UserCount() (int, error) { // --- LDAP & Roles --- // CreateOrUpdateLDAPUser inserts or updates an LDAP user (without password, role='user'). +// Existing accounts with a non-empty pass_hash are never overwritten so that +// a local admin cannot be accidentally converted to an LDAP-only account. func (d *DB) CreateOrUpdateLDAPUser(username string) error { _, err := d.sql.Exec(` INSERT INTO users (username, pass_hash, role, is_ldap) VALUES (?, '', 'user', 1) - ON CONFLICT(username) DO UPDATE SET is_ldap=1; + ON CONFLICT(username) DO UPDATE SET is_ldap=1 + WHERE excluded.pass_hash = '' AND pass_hash = ''; `, username) return err } @@ -274,3 +278,89 @@ func (d *DB) GetACLsForPath(path string) ([]ACLEntry, error) { } return entries, nil } + +// Group represents a group stored in SQLite. +type Group struct { + ID int64 + Name string + IsLDAP bool +} + +// ListGroups returns all groups ordered by name. +func (d *DB) ListGroups() ([]*Group, error) { + rows, err := d.sql.Query(`SELECT id, name, is_ldap FROM groups ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + var groups []*Group + for rows.Next() { + g := &Group{} + if err := rows.Scan(&g.ID, &g.Name, &g.IsLDAP); err != nil { + return nil, err + } + groups = append(groups, g) + } + return groups, rows.Err() +} + +// EnsureGuestUser creates the built-in guest account if it does not already exist. +func (d *DB) EnsureGuestUser() error { + _, err := d.sql.Exec(` + INSERT INTO users (username, pass_hash, role) VALUES ('guest', '', 'guest') + ON CONFLICT(username) DO NOTHING; + `) + return err +} + +// CanUserReadPath returns true if the given username has can_read access on the +// exact path or any of its ancestor folder paths. +func (d *DB) CanUserReadPath(username, path string) bool { + // Build a list of candidate paths: the path itself and each parent segment. + candidates := pathAncestors(path) + for _, p := range candidates { + var n int + d.sql.QueryRow(` + SELECT COUNT(*) FROM acl + JOIN users ON users.id = acl.subject_id AND acl.subject_type = 'user' + WHERE users.username = ? AND acl.path = ? AND acl.can_read = 1 + `, username, p).Scan(&n) + if n > 0 { + return true + } + } + return false +} + +// CanUserViewPath returns true if the given username has can_view access on the +// exact path or any of its ancestor folder paths. +func (d *DB) CanUserViewPath(username, path string) bool { + candidates := pathAncestors(path) + for _, p := range candidates { + var n int + d.sql.QueryRow(` + SELECT COUNT(*) FROM acl + JOIN users ON users.id = acl.subject_id AND acl.subject_type = 'user' + WHERE users.username = ? AND acl.path = ? AND acl.can_view = 1 + `, username, p).Scan(&n) + if n > 0 { + return true + } + } + return false +} + +// pathAncestors returns the path and all its parent segments. +// e.g. "a/b/c" → ["a/b/c", "a/b", "a"] +func pathAncestors(path string) []string { + out := []string{path} + for { + idx := strings.LastIndex(path, "/") + if idx < 0 { + break + } + path = path[:idx] + out = append(out, path) + } + return out +} diff --git a/backend/internal/graph/schema.graphql b/backend/internal/graph/schema.graphql index 4007faa..003f4e3 100644 --- a/backend/internal/graph/schema.graphql +++ b/backend/internal/graph/schema.graphql @@ -33,10 +33,16 @@ type Query { images(slug: String!): [ImageFile!]! # Browse LDAP tree (admin only) - ldapBrowse(url: String, adminUser: String, adminPass: String): LDAPTree! + ldapBrowse(url: String, baseDN: String, adminUser: String, adminPass: String): LDAPTree! # Fetch permissions for a path acl(path: String!): [ACLEntry!]! + + # Returns all users and groups that can be assigned to ACL entries (admin only). + aclSubjects: ACLSubjects! + + # Returns "local", "ldap", or "guest" for a given username. + userAuthType(username: String!): String! } type Mutation { @@ -47,7 +53,7 @@ type Mutation { testLdapConnection(url: String!, adminUser: String, adminPass: String): LDAPTestResult! # Authenticate and receive a bearer token. - login(username: String!, password: String!): String! + login(username: String!, password: String!, ldapPassword: String): String! # Invalidate the current session. logout: Boolean! @@ -101,6 +107,7 @@ type AppConfig { type LDAPConfig { url: String! + baseDN: String! adminUser: String! } @@ -179,8 +186,20 @@ input SetupInput { input LDAPInput { url: String! + baseDN: String adminUser: String! - adminPass: String! + adminPass: String +} + +type ACLSubject { + id: Int! + name: String! + isLdap: Boolean! +} + +type ACLSubjects { + users: [ACLSubject!]! + groups: [ACLSubject!]! } input ACLInput { diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index 22cdc0d..2fe920d 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -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{}{ diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index f59d450..25cb91c 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -18,10 +18,35 @@ export const useAppStore = defineStore('app', () => { } async function login(user: string, password: string) { - const hashed = await hashPassword(user, password) + // Ask the server whether this is a local, LDAP, or guest account. + let authType = 'local' + try { + const at = await gql<{ userAuthType: string }>( + `query AuthType($u: String!) { userAuthType(username: $u) }`, + { u: user }, + ) + authType = at.userAuthType + } catch { + // If the query fails just assume local — login will fail anyway. + } + + let hashedPwd = '' + let ldapPwd = '' + + if (authType === 'guest') { + // Guest needs no password — send empty strings. + } else if (authType === 'ldap') { + // Send plaintext password so the backend can bind against LDAP. + ldapPwd = password + hashedPwd = '' // not used for LDAP + } else { + // Local account: hash the password before sending. + hashedPwd = await hashPassword(user, password) + } + const data = await gql<{ login: string }>( - `mutation Login($u: String!, $p: String!) { login(username: $u, password: $p) }`, - { u: user, p: hashed }, + `mutation Login($u: String!, $p: String!, $lp: String) { login(username: $u, password: $p, ldapPassword: $lp) }`, + { u: user, p: hashedPwd, lp: ldapPwd || null }, ) token.value = data.login username.value = user @@ -29,6 +54,17 @@ export const useAppStore = defineStore('app', () => { localStorage.setItem('username', user) } + async function loginAsGuest() { + const data = await gql<{ login: string }>( + `mutation Login($u: String!, $p: String!) { login(username: $u, password: $p) }`, + { u: 'guest', p: '' }, + ) + token.value = data.login + username.value = 'guest' + localStorage.setItem('token', data.login) + localStorage.setItem('username', 'guest') + } + async function logout() { try { await gql(`mutation { logout }`) @@ -41,6 +77,6 @@ export const useAppStore = defineStore('app', () => { localStorage.removeItem('username') } - return { requiresSetup, token, username, checkStatus, login, logout } + return { requiresSetup, token, username, checkStatus, login, loginAsGuest, logout } }) diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 5567167..ee7146b 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1,13 +1,15 @@ diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 1ba512d..f0f98d2 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -14,11 +14,19 @@ async function submit() { try { await app.login(username.value, password.value) } catch (err: any) { - if (err instanceof Error) { - error.value = err.message - } else { - error.value = 'Failed to sign in' - } + error.value = err instanceof Error ? err.message : 'Failed to sign in' + } finally { + loading.value = false + } +} + +async function continueAsGuest() { + error.value = '' + loading.value = true + try { + await app.loginAsGuest() + } catch (err: any) { + error.value = err instanceof Error ? err.message : 'Failed to sign in as guest' } finally { loading.value = false } @@ -79,6 +87,24 @@ async function submit() { {{ loading ? 'Signing in...' : 'Sign in' }} + +
+
+
+
+
+ or +
+
+ +