docs: API documentation and JSON schema updates

This commit is contained in:
Björn Blomberg
2026-04-10 18:31:38 +02:00
parent 887669c34f
commit c8b63bf8c4
11 changed files with 342 additions and 34 deletions

View File

@@ -9,17 +9,20 @@ import (
"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.
// Session represents an authenticated user session held in memory.
type Session struct {
Username string
Token string
Role string
ExpiresAt time.Time
}
// Manager handles LDAP authentication and in-memory session tracking.
// Manager handles session creation and validation.
type Manager struct {
cfg *config.Config
mu sync.RWMutex
@@ -33,52 +36,115 @@ func NewManager(cfg *config.Config) *Manager {
}
}
// Login authenticates against LDAP and returns a bearer token on success.
func (m *Manager) Login(username, password string) (string, error) {
l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", m.cfg.LDAP.Host, m.cfg.LDAP.Port))
// 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.Host == "" {
return "", "", errors.New("invalid credentials")
}
if err := ldapBind(cfg.LDAP.Host, cfg.LDAP.Port, cfg.LDAP.BindDN, cfg.LDAP.BindPassword); err != nil {
return "", "", errors.New("invalid credentials")
}
if err := ldapUserBind(cfg, username, password); err != nil {
return "", "", errors.New("invalid credentials")
}
tok, err := m.createSession(username, "user")
return tok, "user", err
}
// TestLDAP tests an LDAP configuration by performing a service-account bind.
func TestLDAP(host string, port int, bindDN, bindPassword string) error {
return ldapBind(host, port, bindDN, bindPassword)
}
func ldapBind(host string, port int, bindDN, bindPassword string) error {
l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
if err != nil {
return "", err
return err
}
defer l.Close()
return l.Bind(bindDN, bindPassword)
}
func ldapUserBind(cfg *config.Config, username, password string) error {
l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", cfg.LDAP.Host, cfg.LDAP.Port))
if err != nil {
return err
}
defer l.Close()
if err := l.Bind(m.cfg.LDAP.BindDN, m.cfg.LDAP.BindPassword); err != nil {
return "", err
if err := l.Bind(cfg.LDAP.BindDN, cfg.LDAP.BindPassword); err != nil {
return err
}
sr, err := l.Search(&ldap.SearchRequest{
BaseDN: m.cfg.LDAP.BaseDN,
BaseDN: cfg.LDAP.BaseDN,
Filter: fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username)),
Scope: ldap.ScopeWholeSubtree,
})
if err != nil {
return "", err
return err
}
if len(sr.Entries) != 1 {
return "", errors.New("user not found")
}
userDN := sr.Entries[0].DN
if err := l.Bind(userDN, password); err != nil {
return "", errors.New("invalid credentials")
return errors.New("user not found in LDAP")
}
return l.Bind(sr.Entries[0].DN, password)
}
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 checks a bearer token and returns the associated session.
// 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]

View File

@@ -2,6 +2,12 @@ type Query {
# Returns REQUIRE_SETUP if config is missing.
systemStatus: SystemStatus!
# Fetch current configuration (admin only).
config: AppConfig!
# List directories on the server for UI pickers.
serverDirectories(path: String): [ServerDirectory!]!
# Fetch a document by its slug path.
document(slug: String!): Document
@@ -36,6 +42,15 @@ type Mutation {
# Delete a document.
deleteDocument(slug: String!): Boolean!
# Modify LDAP Configuration (admin only).
updateLdapConfig(input: LDAPInput): Boolean!
# Modify Storage Path (admin only).
updateStoragePath(path: String!): Boolean!
# Change the current user's password.
changePassword(old: String!, new: String!): Boolean!
}
# ── Types ──────────────────────────────────────────────────────────────────────
@@ -45,6 +60,23 @@ enum SystemStatus {
REQUIRE_SETUP
}
type AppConfig {
storagePath: String!
ldap: LDAPConfig
}
type LDAPConfig {
host: String!
port: Int!
baseDN: String!
bindDN: String!
}
type ServerDirectory {
name: String!
path: String!
}
type Document {
slug: String!
content: String!