auth: Registrierung, Login, Logout, per-user Sessions (Backend)
This commit is contained in:
parent
c7b4acea02
commit
0ef39cffd6
12 changed files with 703 additions and 40 deletions
|
|
@ -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
112
internal/store/users.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue