95 lines
2.6 KiB
Go
95 lines
2.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
const sessionTTL = 24 * time.Hour
|
|
|
|
type Session struct {
|
|
Username string
|
|
}
|
|
|
|
// SessionStore persists sessions in an embedded SQLite database so they survive
|
|
// process restarts. Expired rows are dropped lazily on read.
|
|
type SessionStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewSessionStore opens (creating if needed) the SQLite database at path and
|
|
// ensures the sessions table exists. The parent directory is created too.
|
|
func NewSessionStore(path string) (*SessionStore, error) {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
|
return nil, fmt.Errorf("session db dir: %w", err)
|
|
}
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open session db: %w", err)
|
|
}
|
|
// ponytail: single connection serializes access, avoiding SQLite's
|
|
// "database is locked" under concurrent writes. A sysadmin panel's session
|
|
// rate is trivial; switch to WAL + a real pool only if that ever bites.
|
|
db.SetMaxOpenConns(1)
|
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS sessions (
|
|
token TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
)`); err != nil {
|
|
return nil, fmt.Errorf("create sessions table: %w", err)
|
|
}
|
|
return &SessionStore{db: db}, nil
|
|
}
|
|
|
|
// Ping reports whether the session database is reachable. Used by the health
|
|
// check.
|
|
func (s *SessionStore) Ping() error { return s.db.Ping() }
|
|
|
|
func (s *SessionStore) Create(username string) (string, error) {
|
|
token := randomToken()
|
|
expires := time.Now().Add(sessionTTL)
|
|
if _, err := s.db.Exec(
|
|
`INSERT INTO sessions (token, username, expires_at) VALUES (?, ?, ?)`,
|
|
token, username, expires.Unix(),
|
|
); err != nil {
|
|
return "", err
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// Delete removes a session, invalidating it immediately (logout). Deleting an
|
|
// unknown token is a no-op.
|
|
func (s *SessionStore) Delete(token string) error {
|
|
_, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
|
|
return err
|
|
}
|
|
|
|
func (s *SessionStore) GetByToken(token string) (Session, bool) {
|
|
var username string
|
|
var expires int64
|
|
err := s.db.QueryRow(
|
|
`SELECT username, expires_at FROM sessions WHERE token = ?`, token,
|
|
).Scan(&username, &expires)
|
|
if err != nil {
|
|
return Session{}, false
|
|
}
|
|
if time.Now().Unix() > expires {
|
|
s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
|
|
return Session{}, false
|
|
}
|
|
return Session{Username: username}, true
|
|
}
|
|
|
|
func randomToken() string {
|
|
b := make([]byte, 32)
|
|
rand.Read(b) // never fails; rand.Read panics internally on misconfigured platforms.
|
|
return hex.EncodeToString(b)
|
|
}
|