package auth import ( "crypto/rand" "database/sql" "encoding/base64" "errors" "fmt" "net/http" "strings" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" "gobsidian/internal/config" ) type Service struct { DB *sql.DB Config *config.Config } // HasReadAccess determines if a user (or the public) can read a given path. // Semantics: // - If no permission rows exist matching the path (by prefix), access is ALLOWED by default. // - If permission rows exist for the path: // - Access is allowed if any matching row has can_read=1 and the user belongs to that group. // - Unauthenticated users are treated as belonging only to the implicit 'public' group. // - Any row for group name 'public' grants access to everyone. // Path matching uses prefix match on path_prefix. func (s *Service) HasReadAccess(userID *int64, path string) (bool, error) { // First check if any permission rows exist for this path prefix var total int err := s.DB.QueryRow(` SELECT COUNT(1) FROM permissions p WHERE ? LIKE p.path_prefix || '%' `, path).Scan(&total) if err != nil { return false, err } // Default allow when no permissions defined for this path if total == 0 { return true, nil } // If user is nil (public), allow if any matching permission for group 'public' with can_read if userID == nil { var exists int err := s.DB.QueryRow(` SELECT 1 FROM permissions p JOIN groups g ON g.id = p.group_id WHERE p.can_read = 1 AND ? LIKE p.path_prefix || '%' AND g.name = 'public' LIMIT 1 `, path).Scan(&exists) if err != nil { if errors.Is(err, sql.ErrNoRows) { return false, nil } return false, err } return true, nil } // For authenticated users, allow if any matching row with can_read and group's either 'public' or user is member var exists int err = s.DB.QueryRow(` SELECT 1 FROM permissions p JOIN groups g ON g.id = p.group_id LEFT JOIN user_groups ug ON ug.group_id = g.id AND ug.user_id = ? WHERE p.can_read = 1 AND ? LIKE p.path_prefix || '%' AND (g.name = 'public' OR ug.user_id IS NOT NULL) LIMIT 1 `, *userID, path).Scan(&exists) if err != nil { if errors.Is(err, sql.ErrNoRows) { return false, nil } return false, err } return true, nil } // IsUserInGroup returns true if the given user is a member of the given group name. func (s *Service) IsUserInGroup(userID int64, groupName string) (bool, error) { var exists int err := s.DB.QueryRow(` SELECT 1 FROM user_groups ug JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = ? AND g.name = ? LIMIT 1 `, userID, groupName).Scan(&exists) if err != nil { if errors.Is(err, sql.ErrNoRows) { return false, nil } return false, err } return true, nil } type User struct { ID int64 Username string Email string PasswordHash string IsActive bool EmailConfirmed bool MFASecret sql.NullString CreatedAt time.Time UpdatedAt time.Time } func Open(cfg *config.Config) (*Service, error) { if strings.ToLower(cfg.DBType) != "sqlite" { return nil, fmt.Errorf("unsupported DB type: %s", cfg.DBType) } dsn := cfg.DBPath db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil { return nil, err } s := &Service{DB: db, Config: cfg} if err := s.migrate(); err != nil { return nil, err } if err := s.ensureDefaultAdmin(); err != nil { return nil, err } return s, nil } func (s *Service) migrate() error { stmts := []string{ `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, email_confirmed INTEGER NOT NULL DEFAULT 0, mfa_secret TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )` , `CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL )` , `CREATE TABLE IF NOT EXISTS user_groups ( user_id INTEGER NOT NULL, group_id INTEGER NOT NULL, PRIMARY KEY(user_id, group_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE )` , `CREATE TABLE IF NOT EXISTS permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, path_prefix TEXT NOT NULL, can_read INTEGER NOT NULL DEFAULT 1, can_write INTEGER NOT NULL DEFAULT 0, can_delete INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE )` , `CREATE TABLE IF NOT EXISTS email_verification_tokens ( user_id INTEGER NOT NULL, token TEXT NOT NULL UNIQUE, expires_at DATETIME NOT NULL, PRIMARY KEY(user_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )` , `CREATE TABLE IF NOT EXISTS password_reset_tokens ( user_id INTEGER NOT NULL, token TEXT NOT NULL UNIQUE, expires_at DATETIME NOT NULL, PRIMARY KEY(user_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )` , `CREATE TABLE IF NOT EXISTS mfa_enrollments ( user_id INTEGER PRIMARY KEY, secret TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )` , // Access logs for all requests `CREATE TABLE IF NOT EXISTS access_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NULL, ip TEXT NOT NULL, method TEXT NOT NULL, path TEXT NOT NULL, status INTEGER NOT NULL, duration_ms INTEGER NOT NULL, user_agent TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL )` , `CREATE INDEX IF NOT EXISTS idx_access_logs_created_at ON access_logs(created_at)` , `CREATE INDEX IF NOT EXISTS idx_access_logs_user_id ON access_logs(user_id)` , `CREATE INDEX IF NOT EXISTS idx_access_logs_ip ON access_logs(ip)` , // Error logs for server-side errors `CREATE TABLE IF NOT EXISTS error_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NULL, ip TEXT NULL, path TEXT NULL, message TEXT NOT NULL, stack TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL )` , `CREATE INDEX IF NOT EXISTS idx_error_logs_created_at ON error_logs(created_at)` , // Failed login attempts (both password and MFA tracked separately) `CREATE TABLE IF NOT EXISTS failed_logins ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip TEXT NOT NULL, user_id INTEGER NULL, username TEXT NULL, type TEXT NOT NULL CHECK(type IN ('password','mfa')), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL )` , `CREATE INDEX IF NOT EXISTS idx_failed_logins_ip_created ON failed_logins(ip, created_at)` , `CREATE INDEX IF NOT EXISTS idx_failed_logins_type_created ON failed_logins(type, created_at)` , // IP bans and whitelist `CREATE TABLE IF NOT EXISTS ip_bans ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip TEXT NOT NULL UNIQUE, reason TEXT NULL, until DATETIME NULL, permanent INTEGER NOT NULL DEFAULT 0, whitelisted INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )` , `CREATE INDEX IF NOT EXISTS idx_ip_bans_ip ON ip_bans(ip)` , `CREATE INDEX IF NOT EXISTS idx_ip_bans_whitelist ON ip_bans(whitelisted)` , `CREATE INDEX IF NOT EXISTS idx_ip_bans_permanent ON ip_bans(permanent)` , } for _, stmt := range stmts { if _, err := s.DB.Exec(stmt); err != nil { return err } } return nil } func (s *Service) ensureDefaultAdmin() error { tx, err := s.DB.Begin() if err != nil { return err } defer func() { if err != nil { _ = tx.Rollback() } }() // Ensure groups exist if _, err = tx.Exec(`INSERT OR IGNORE INTO groups (name) VALUES (?), (?)`, "admin", "public"); err != nil { return err } // Ensure admin user exists var adminID int64 err = tx.QueryRow(`SELECT id FROM users WHERE username = ?`, "admin").Scan(&adminID) if err != nil { if errors.Is(err, sql.ErrNoRows) { pwHash, e := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) if e != nil { return e } res, e := tx.Exec(`INSERT INTO users (username, email, password_hash, is_active, email_confirmed) VALUES (?,?,?,?,?)`, "admin", "admin@local", string(pwHash), 1, 1, ) if e != nil { return e } adminID, err = res.LastInsertId() if err != nil { return err } } else { return err } } // Ensure admin group id var adminGroupID int64 if err = tx.QueryRow(`SELECT id FROM groups WHERE name = ?`, "admin").Scan(&adminGroupID); err != nil { return err } // Ensure membership admin -> admin group if _, err = tx.Exec(`INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)`, adminID, adminGroupID); err != nil { return err } // Commit if err = tx.Commit(); err != nil { return err } return nil } func (s *Service) Authenticate(usernameOrEmail, password string) (*User, error) { u := &User{} row := s.DB.QueryRow(`SELECT id, username, email, password_hash, is_active, email_confirmed, mfa_secret, created_at, updated_at FROM users WHERE username = ? OR email = ?`, usernameOrEmail, usernameOrEmail) var mfa sql.NullString if err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.IsActive, &u.EmailConfirmed, &mfa, &u.CreatedAt, &u.UpdatedAt); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, errors.New("invalid credentials") } return nil, err } if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { return nil, errors.New("invalid credentials") } u.MFASecret = mfa if !u.IsActive { return nil, errors.New("account not active") } if s.Config.RequireEmailConfirmation && !u.EmailConfirmed { return nil, errors.New("email not confirmed") } return u, nil } // CSRF utilities const csrfSessionKey = "csrf_token" const csrfHeader = "X-CSRF-Token" const csrfFormField = "csrf_token" func randomToken(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func IssueCSRF() gin.HandlerFunc { return func(c *gin.Context) { sessAny, exists := c.Get("session") if !exists { c.Next() return } sess, _ := sessAny.(map[string]interface{}) var token string if v, ok := sess[csrfSessionKey].(string); ok && v != "" { token = v } else { var err error token, err = randomToken(32) if err == nil { sess[csrfSessionKey] = token } } c.Set("csrf_token", token) c.Next() } } func RequireCSRF() gin.HandlerFunc { return func(c *gin.Context) { if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead || c.Request.Method == http.MethodOptions { c.Next() return } var token string // header first if h := c.GetHeader(csrfHeader); h != "" { token = h } else { token = c.PostForm(csrfFormField) } sessAny, exists := c.Get("session") if !exists { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "missing session for CSRF"}) return } sess, _ := sessAny.(map[string]interface{}) if expected, ok := sess[csrfSessionKey].(string); !ok || expected == "" || token == "" || !hmacEqual(expected, token) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid CSRF token"}) return } c.Next() } } func hmacEqual(a, b string) bool { if len(a) != len(b) { return false } var v byte for i := 0; i < len(a); i++ { v |= a[i] ^ b[i] } return v == 0 } // Session helpers const sessionCookieName = "gobsidian_session" // AttachSession should be installed early to create a simple session map backed by cookie store in handlers package func AttachSession() gin.HandlerFunc { return func(c *gin.Context) { // Handlers already use gorilla sessions. Here we just ensure a map exists to carry CSRF and auth info for templates. if _, exists := c.Get("session"); !exists { c.Set("session", map[string]interface{}{}) } c.Next() } } // RequireAuth checks presence of user_id in context func RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { if _, exists := c.Get("user_id"); !exists { c.Redirect(http.StatusFound, "/editor/login") c.Abort() return } c.Next() } }