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

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
}