feat: persist authenticated sessions

This commit is contained in:
Josh Creek
2026-08-31 22:23:59 +01:00
parent ae164e625a
commit b3284d4bd6
3 changed files with 121 additions and 1 deletions
+87
View File
@@ -0,0 +1,87 @@
package store
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
const (
SessionInsertSQL = `INSERT INTO sessions (session_id, player_id, token_digest, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5)`
SessionSelectSQL = `SELECT session_id, player_id, token_digest, expires_at, revoked_at
FROM sessions
WHERE session_id = $1`
SessionRevokeSQL = `UPDATE sessions SET revoked_at = COALESCE(revoked_at, $2)
WHERE session_id = $1`
)
// PostgresSessions persists only a SHA-256 token digest. The plaintext token
// is returned once by Issue and is never sent to SQL or logged by this layer.
type PostgresSessions struct{ DB *sql.DB }
func (s PostgresSessions) Issue(ctx context.Context, playerID string, lifetime time.Duration, now time.Time) (domain.Session, string, error) {
if s.DB == nil || playerID == "" || lifetime <= 0 || now.IsZero() {
return domain.Session{}, "", domain.ErrSessionRejected
}
sessionID, err := opaqueSessionValue()
if err != nil {
return domain.Session{}, "", err
}
token, err := opaqueSessionValue()
if err != nil {
return domain.Session{}, "", err
}
session := domain.Session{SessionID: sessionID, PlayerID: playerID, ExpiresAt: now.Add(lifetime)}
digest := sha256.Sum256([]byte(token))
if _, err := s.DB.ExecContext(ctx, SessionInsertSQL, session.SessionID, session.PlayerID, digest[:], session.ExpiresAt, now); err != nil {
return domain.Session{}, "", err
}
return session, token, nil
}
func (s PostgresSessions) Authenticate(ctx context.Context, sessionID, token string, now time.Time) (domain.Session, error) {
if s.DB == nil || sessionID == "" || token == "" || now.IsZero() {
return domain.Session{}, domain.ErrSessionRejected
}
var session domain.Session
var digestBytes []byte
var revokedAt sql.NullTime
if err := s.DB.QueryRowContext(ctx, SessionSelectSQL, sessionID).Scan(&session.SessionID, &session.PlayerID, &digestBytes, &session.ExpiresAt, &revokedAt); err != nil {
return domain.Session{}, domain.ErrSessionRejected
}
provided := sha256.Sum256([]byte(token))
if len(digestBytes) != sha256.Size || subtle.ConstantTimeCompare(digestBytes, provided[:]) != 1 || (revokedAt.Valid && !revokedAt.Time.IsZero()) || !now.Before(session.ExpiresAt) {
return domain.Session{}, domain.ErrSessionRejected
}
return session, nil
}
func (s PostgresSessions) Revoke(ctx context.Context, sessionID string, now time.Time) error {
if s.DB == nil || sessionID == "" || now.IsZero() {
return domain.ErrSessionRejected
}
result, err := s.DB.ExecContext(ctx, SessionRevokeSQL, sessionID, now)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil || changed != 1 {
return domain.ErrSessionRejected
}
return nil
}
func opaqueSessionValue() (string, error) {
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
return "", err
}
return hex.EncodeToString(value), nil
}
+33
View File
@@ -0,0 +1,33 @@
package store
import (
"testing"
"time"
)
func TestSessionSQLStoresDigestAndEnforcesRevocationBoundary(t *testing.T) {
for query, fragments := range map[string][]string{
SessionInsertSQL: {"token_digest", "expires_at", "created_at"},
SessionSelectSQL: {"token_digest", "revoked_at", "WHERE session_id = $1"},
SessionRevokeSQL: {"COALESCE(revoked_at", "WHERE session_id = $1"},
} {
for _, fragment := range fragments {
if !contains(query, fragment) {
t.Fatalf("query %q missing %q", query, fragment)
}
}
}
}
func TestPostgresSessionsRejectsInvalidArgumentsWithoutDatabase(t *testing.T) {
sessions := PostgresSessions{}
if _, _, err := sessions.Issue(nil, "player-1", time.Minute, time.Unix(1000, 0)); err == nil {
t.Fatal("invalid issue accepted")
}
if _, err := sessions.Authenticate(nil, "session-1", "token-1", time.Unix(1000, 0)); err == nil {
t.Fatal("invalid authentication accepted")
}
if err := sessions.Revoke(nil, "session-1", time.Unix(1000, 0)); err == nil {
t.Fatal("invalid revoke accepted")
}
}