13e8e73da7
The browser UI signs in with a Discord authorization code grant (identify + guilds.members.read) instead of a shared password. Guild membership is the gate; the owner's Discord ID is the only identity allowed in while registration is closed. Sessions become rows in a sessions table with opaque random ids — the cookie carries only the id, looked up and expiry-checked per request — so deleting a row revokes a session. HMAC cookie signing, its derived key, and WEB_PASSWORD are gone, and no replacement signing secret is introduced (ADR-0002). Discord's API base is configurable (DISCORD_API_BASE); the full flow is tested through the real router against a local stub, including the form-encoded token exchange Discord rejects if sent as JSON.
60 lines
1.9 KiB
Go
60 lines
1.9 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"time"
|
|
)
|
|
|
|
// Session is one browser login: an opaque id the cookie carries verbatim,
|
|
// the Reader it belongs to, and when it stops being valid.
|
|
type Session struct {
|
|
ID string
|
|
ReaderID int64
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// CreateSession stores a new session row for reader. The id is generated by
|
|
// the caller (session.NewID) — the store only persists it. Expired rows that
|
|
// were never looked up are swept here: this is the one write every login
|
|
// makes, so the table stays bounded without a background job.
|
|
func (s *Store) CreateSession(id string, readerID int64, ttl time.Duration) (Session, error) {
|
|
expires := time.Now().Add(ttl)
|
|
_, err := s.db.Exec(`INSERT INTO sessions (id, reader_id, expires_at) VALUES ($1, $2, $3)`,
|
|
id, readerID, expires)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
_, err = s.db.Exec(`DELETE FROM sessions WHERE expires_at < now()`)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
return Session{ID: id, ReaderID: readerID, ExpiresAt: expires}, nil
|
|
}
|
|
|
|
// GetSession returns the live session row for id, or ok=false when the id is
|
|
// unknown or expired. An expired row is deleted on the way out, so the table
|
|
// never grows past sessions that are still valid.
|
|
func (s *Store) GetSession(id string, now time.Time) (Session, bool, error) {
|
|
var sess Session
|
|
err := s.db.QueryRow(
|
|
`SELECT id, reader_id, expires_at FROM sessions WHERE id = $1`, id,
|
|
).Scan(&sess.ID, &sess.ReaderID, &sess.ExpiresAt)
|
|
if err == sql.ErrNoRows {
|
|
return Session{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return Session{}, false, err
|
|
}
|
|
if !sess.ExpiresAt.After(now) {
|
|
_, _ = s.db.Exec(`DELETE FROM sessions WHERE id = $1`, id)
|
|
return Session{}, false, nil
|
|
}
|
|
return sess, true, nil
|
|
}
|
|
|
|
// DeleteSession revokes one session. Deleting an unknown id is not an error.
|
|
func (s *Store) DeleteSession(id string) error {
|
|
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = $1`, id)
|
|
return err
|
|
}
|