package store import ( "context" "database/sql" "errors" "time" "github.com/barerepo/server/internal/token" ) // SessionLife bounds an unused session, kept in a table because revocation beats statelessness. const SessionLife = 30 * 24 * time.Hour // NewSession issues a session for an account and returns the cookie value. func (db *DB) NewSession(ctx context.Context, account string) (string, error) { tok, hash, err := token.New(token.Session) if err != nil { return "", err } now := now() _, err = db.ExecContext(ctx, `INSERT INTO tokens (kind, hash, account, scope, label, created_at, expires_at) VALUES (?, ?, ?, '', '', ?, ?)`, string(token.Session), hash, account, now.Unix(), now.Add(SessionLife).Unix()) if err != nil { return "", err } return tok, nil } // SessionAccount returns who a session cookie belongs to, or ErrNotFound. func (db *DB) SessionAccount(ctx context.Context, cookie string) (string, error) { t, err := db.AccountForToken(ctx, token.Session, cookie) if err != nil { return "", err } return t.Account, nil } // EndSession revokes one session. Signing out has to actually sign out. func (db *DB) EndSession(ctx context.Context, cookie string) error { _, err := db.ExecContext(ctx, `DELETE FROM tokens WHERE kind = ? AND hash = ?`, string(token.Session), token.Hash(cookie)) return err } // ClaimLife is short, because a claim is carried from one terminal to one browser and no further. const ClaimLife = 10 * time.Minute // NewClaim issues the one-use code that trades a signature made in a terminal for a session. func (db *DB) NewClaim(ctx context.Context, account string) (string, error) { tok, hash, err := token.New(token.Claim) if err != nil { return "", err } now := now() _, err = db.ExecContext(ctx, `INSERT INTO tokens (kind, hash, account, scope, label, created_at, expires_at) VALUES (?, ?, ?, '', '', ?, ?)`, string(token.Claim), hash, account, now.Unix(), now.Add(ClaimLife).Unix()) if err != nil { return "", err } return tok, nil } // TakeClaim spends a claim and names its account, so a pasted link works once and never again. func (db *DB) TakeClaim(ctx context.Context, code string) (string, error) { t, err := db.AccountForToken(ctx, token.Claim, code) if err != nil { return "", err } if _, err := db.ExecContext(ctx, `DELETE FROM tokens WHERE id = ?`, t.ID); err != nil { return "", err } return t.Account, nil } // Challenge is chapter 10's nonce, on disk so a restart costs nobody and verifying deletes it. type Challenge struct { Nonce string Account string } // ChallengeLife is ten minutes, because signing means leaving for a terminal and coming back. const ChallengeLife = 10 * time.Minute // NewChallenge stores a nonce for an account. func (db *DB) NewChallenge(ctx context.Context, account, nonce string) error { now := now() _, err := db.ExecContext(ctx, `INSERT INTO challenges (nonce, account, expires_at) VALUES (?, ?, ?)`, nonce, account, now.Add(ChallengeLife).Unix()) return err } // TakeChallenge consumes a nonce once, whether or not its signature was any good. func (db *DB) TakeChallenge(ctx context.Context, nonce string) (*Challenge, error) { var c Challenge var expires int64 err := db.QueryRowContext(ctx, `SELECT nonce, account, expires_at FROM challenges WHERE nonce = ?`, nonce). Scan(&c.Nonce, &c.Account, &expires) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } // The delete is what spends it, so the caller that removed the row is the one that may use it. res, err := db.ExecContext(ctx, `DELETE FROM challenges WHERE nonce = ?`, nonce) if err != nil { return nil, err } spent, err := res.RowsAffected() if err != nil { return nil, err } if spent == 0 { return nil, ErrNotFound } if now().Unix() > expires { return nil, ErrNotFound } return &c, nil } // SweepExpired drops spent nonces and dead sessions, which accumulate and are worth nothing. func (db *DB) SweepExpired(ctx context.Context) error { cutoff := now().Unix() if _, err := db.ExecContext(ctx, `DELETE FROM challenges WHERE expires_at < ?`, cutoff); err != nil { return err } if _, err := db.ExecContext(ctx, `DELETE FROM signup_challenges WHERE expires_at < ?`, cutoff); err != nil { return err } _, err := db.ExecContext(ctx, `DELETE FROM tokens WHERE expires_at IS NOT NULL AND expires_at < ?`, cutoff) return err } // SignupChallenge holds a name and key while the server waits for proof of the private half. type SignupChallenge struct { Nonce string Name string PubKey string } // NewSignupChallenge parks a signup until its signature arrives. func (db *DB) NewSignupChallenge(ctx context.Context, nonce, name, pubkey string) error { _, err := db.ExecContext(ctx, `INSERT INTO signup_challenges (nonce, name, pubkey, expires_at) VALUES (?, ?, ?, ?)`, nonce, name, pubkey, now().Add(ChallengeLife).Unix()) return err } // TakeSignupChallenge spends a signup nonce on first use, good signature or not. func (db *DB) TakeSignupChallenge(ctx context.Context, nonce string) (*SignupChallenge, error) { var c SignupChallenge var expires int64 err := db.QueryRowContext(ctx, `SELECT nonce, name, pubkey, expires_at FROM signup_challenges WHERE nonce = ?`, nonce). Scan(&c.Nonce, &c.Name, &c.PubKey, &expires) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } // The delete is what spends it, so two requests racing one nonce make at most one account. res, err := db.ExecContext(ctx, `DELETE FROM signup_challenges WHERE nonce = ?`, nonce) if err != nil { return nil, err } spent, err := res.RowsAffected() if err != nil { return nil, err } if spent == 0 { return nil, ErrNotFound } if now().Unix() > expires { return nil, ErrNotFound } return &c, nil }