Files
llmgw/internal/store/sessions.go
T
2026-10-04 16:30:56 +02:00

134 lines
4.4 KiB
Go

package store
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"time"
)
// SessionTTL is how long a sign-in lasts.
const SessionTTL = 14 * 24 * time.Hour
// SessionIdle is how long a sign-in lasts unused. A browser left signed in
// on a shared machine, or a cookie copied off one, stops working a day after
// it was last used rather than two weeks after it was made.
const SessionIdle = 24 * time.Hour
// ErrNoSession says a session cookie names nothing usable.
var ErrNoSession = errors.New("not signed in")
// Session is an open sign-in.
type Session struct {
UserID string
CSRFHash []byte
ExpiresAt time.Time
}
// NewSessionToken mints a session or CSRF token.
func NewSessionToken() (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return encodeBase62(raw), nil
}
// CreateSession opens a session and returns nothing: the caller already holds
// the tokens, and neither is stored in the clear.
func (s *DB) CreateSession(ctx context.Context, userID, token, csrf string, ttl time.Duration) error {
if ttl <= 0 {
ttl = SessionTTL
}
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO sessions (token_hash, user_id, csrf_hash, created_at, expires_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?)`,
HashKey(token), userID, HashKey(csrf), millis(now), millis(now.Add(ttl)), millis(now))
return err
}
// SessionByToken resolves a cookie to its session and the person it belongs to.
//
// One query rather than two: a session whose user has been suspended must stop
// working immediately, and checking that in a second round trip is a check
// somebody eventually forgets.
func (s *DB) SessionByToken(ctx context.Context, token string) (Session, User, error) {
row := s.db.QueryRowContext(ctx,
`SELECT s.user_id, s.csrf_hash, s.expires_at, s.last_seen,
u.id, u.email, u.name, u.password_hash, u.state, COALESCE(u.project_id, ''),
u.created_at, u.approved_at, u.approved_by, u.last_login_at, u.is_admin, u.staff_role
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?`, HashKey(token))
var sess Session
var expires, lastSeen int64
var u User
var state string
var created int64
var approved, lastLogin sql.NullInt64
var approvedBy sql.NullString
if err := row.Scan(&sess.UserID, &sess.CSRFHash, &expires, &lastSeen,
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &state, &u.ProjectID,
&created, &approved, &approvedBy, &lastLogin, &u.IsAdmin, &u.Role); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Session{}, User{}, ErrNoSession
}
return Session{}, User{}, err
}
sess.ExpiresAt = fromMillis(expires)
if now := time.Now(); now.After(sess.ExpiresAt) || now.Sub(fromMillis(lastSeen)) > SessionIdle {
return Session{}, User{}, ErrNoSession
}
u.State = UserState(state)
u.CreatedAt = fromMillis(created)
u.ApprovedAt = fromNullMillis(approved)
u.ApprovedBy = approvedBy.String
u.LastLoginAt = fromNullMillis(lastLogin)
if !u.CanSignIn() {
// Suspended between sign-in and now. The session exists; it just stops
// meaning anything.
return Session{}, User{}, ErrNoSession
}
return sess, u, nil
}
// TouchSession records activity, at most once a minute.
func (s *DB) TouchSession(ctx context.Context, token string, at time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE sessions SET last_seen = ? WHERE token_hash = ? AND last_seen < ?`,
millis(at), HashKey(token), millis(at.Add(-time.Minute)))
return err
}
// DeleteSession signs one browser out.
func (s *DB) DeleteSession(ctx context.Context, token string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, HashKey(token))
return err
}
// DeleteUserSessions signs a person out everywhere.
//
// What suspension and a password change both need: leaving old sessions open
// would make either of them advisory.
func (s *DB) DeleteUserSessions(ctx context.Context, userID string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
return err
}
// PurgeSessions removes rows expired, or idle too long, by a moment.
func (s *DB) PurgeSessions(ctx context.Context, before time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ? OR last_seen < ?`,
millis(before), millis(before.Add(-SessionIdle)))
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
}