bcc6b45515
Implements #23 per ADR-0002. - Discord authorization code grant (identify + guilds.members.read), form-encoded token exchange - Guild membership gate via the single-guild endpoint; optional DISCORD_REQUIRED_ROLE (empty default) - Owner Discord ID is the only identity allowed to sign in - Sessions are DB rows with opaque random ids; cookie carries only the id; expiry enforced; delete = revoke - HMAC session signing, derived key, and WEB_PASSWORD removed; no replacement signing secret - Login rate limiting preserved on the callback - Full flow tested through the real router against a local Discord stub (DISCORD_API_BASE) - Env: DISCORD_CLIENT_ID/_CLIENT_SECRET/_GUILD_ID/_REQUIRED_ROLE/_API_BASE/_REDIRECT_URI; docs updated go test ./... passes. Reviewed-on: #31 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
70 lines
2.3 KiB
Go
70 lines
2.3 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 in the same transaction: 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) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
expires := time.Now().Add(ttl)
|
|
if _, err := tx.Exec(`INSERT INTO sessions (id, reader_id, expires_at) VALUES ($1, $2, $3)`,
|
|
id, readerID, expires); err != nil {
|
|
return Session{}, err
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM sessions WHERE expires_at < now()`); err != nil {
|
|
return Session{}, err
|
|
}
|
|
if err := tx.Commit(); 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) {
|
|
// Best-effort: the row is dead either way; failing the request over a
|
|
// cleanup delete would only hide the real error. CreateSession's
|
|
// sweep catches anything this misses.
|
|
_, _ = 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
|
|
}
|