auth: Registrierung, Login, Logout, per-user Sessions (Backend)

This commit is contained in:
OpenClaw Dev 2026-10-31 11:28:17 +01:00
parent c7b4acea02
commit 0ef39cffd6
12 changed files with 703 additions and 40 deletions

View file

@ -54,6 +54,15 @@ CREATE TABLE IF NOT EXISTS summaries(
upto_message_id INTEGER NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id, id);
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL, created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS auth_tokens(
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
`
// migrations add columns to databases created by older versions.
@ -61,6 +70,7 @@ var migrations = []struct{ table, column, ddl string }{
{"sessions", "provider", "ALTER TABLE sessions ADD COLUMN provider TEXT NOT NULL DEFAULT ''"},
{"sessions", "model", "ALTER TABLE sessions ADD COLUMN model TEXT NOT NULL DEFAULT ''"},
{"messages", "cancelled", "ALTER TABLE messages ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0"},
{"sessions", "user_id", "ALTER TABLE sessions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0"},
}
func migrate(db *sql.DB) error {
@ -103,10 +113,15 @@ func newID() string {
}
func (s *Store) CreateSession(ctx context.Context, title string) (Session, error) {
return s.CreateSessionFor(ctx, 0, title)
}
// CreateSessionFor creates a session owned by userID (0 = legacy/admin).
func (s *Store) CreateSessionFor(ctx context.Context, userID int64, title string) (Session, error) {
now := time.Now().UTC()
ss := Session{ID: newID(), Title: title, CreatedAt: now, UpdatedAt: now}
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id,title,created_at,updated_at) VALUES(?,?,?,?)`,
ss.ID, ss.Title, now.UnixMilli(), now.UnixMilli())
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id,title,user_id,created_at,updated_at) VALUES(?,?,?,?,?)`,
ss.ID, ss.Title, userID, now.UnixMilli(), now.UnixMilli())
return ss, err
}
@ -122,7 +137,16 @@ func (s *Store) GetSession(ctx context.Context, id string) (Session, error) {
}
func (s *Store) ListSessions(ctx context.Context, limit int) ([]Session, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,title,provider,model,created_at,updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?`, limit)
return s.ListSessionsFor(ctx, AllUsers, limit)
}
// AllUsers disables per-user scoping (admin / static token).
const AllUsers int64 = -1
// ListSessionsFor lists the sessions of userID (AllUsers = every session).
func (s *Store) ListSessionsFor(ctx context.Context, userID int64, limit int) ([]Session, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,title,provider,model,created_at,updated_at FROM sessions
WHERE (?<0 OR user_id=?) ORDER BY updated_at DESC LIMIT ?`, userID, userID, limit)
if err != nil {
return nil, err
}

112
internal/store/users.go Normal file
View file

@ -0,0 +1,112 @@
package store
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
"time"
)
var ErrUserExists = errors.New("username already taken")
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
hash string
}
func (u User) PasswordHash() string { return u.hash }
// CreateUser inserts a user. The first user ever created adopts all sessions
// that existed before accounts were introduced (user_id 0).
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) {
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return User{}, err
}
defer tx.Rollback()
var n int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
return User{}, err
}
res, err := tx.ExecContext(ctx, `INSERT INTO users(username,password_hash,created_at) VALUES(?,?,?)`, username, passwordHash, now.UnixMilli())
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return User{}, ErrUserExists
}
return User{}, err
}
id, _ := res.LastInsertId()
if n == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET user_id=? WHERE user_id=0`, id); err != nil {
return User{}, err
}
}
return User{ID: id, Username: username, CreatedAt: now, hash: passwordHash}, tx.Commit()
}
func (s *Store) UserByName(ctx context.Context, username string) (User, error) {
var u User
var c int64
err := s.db.QueryRowContext(ctx, `SELECT id,username,password_hash,created_at FROM users WHERE username=?`, username).Scan(&u.ID, &u.Username, &u.hash, &c)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
u.CreatedAt = time.UnixMilli(c).UTC()
return u, err
}
func hashToken(t string) string {
h := sha256.Sum256([]byte(t))
return hex.EncodeToString(h[:])
}
// IssueToken creates a random opaque token; only its SHA-256 is stored.
func (s *Store) IssueToken(ctx context.Context, userID int64, ttl time.Duration) (string, time.Time, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", time.Time{}, err
}
tok := "bc_" + base64.RawURLEncoding.EncodeToString(b)
now := time.Now().UTC()
exp := now.Add(ttl)
_, err := s.db.ExecContext(ctx, `INSERT INTO auth_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`,
hashToken(tok), userID, now.UnixMilli(), exp.UnixMilli())
s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE expires_at<?`, now.UnixMilli())
return tok, exp, err
}
// UserByToken resolves a non-expired token (lookup by hash, so no timing leak on the secret).
func (s *Store) UserByToken(ctx context.Context, tok string) (User, error) {
var u User
var c int64
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.created_at FROM auth_tokens t JOIN users u ON u.id=t.user_id
WHERE t.token_hash=? AND t.expires_at>?`, hashToken(tok), time.Now().UTC().UnixMilli()).Scan(&u.ID, &u.Username, &c)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
u.CreatedAt = time.UnixMilli(c).UTC()
return u, err
}
func (s *Store) RevokeToken(ctx context.Context, tok string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE token_hash=?`, hashToken(tok))
return err
}
// SessionOwnedBy returns ErrNotFound unless the session exists and belongs to userID (AllUsers = any).
func (s *Store) SessionOwnedBy(ctx context.Context, userID int64, id string) error {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions WHERE id=? AND (?<0 OR user_id=?)`, id, userID, userID).Scan(&n)
if err == nil && n == 0 {
return ErrNotFound
}
return err
}