Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
134 lines
4.4 KiB
Go
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
|
|
}
|