package store import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "errors" "strings" "time" ) var ErrUserExists = errors.New("username already taken") type User struct { ID int64 `json:"id"` Username string `json:"username"` CreatedAt time.Time `json:"created_at"` hash string } func (u User) PasswordHash() string { return u.hash } // CreateUser inserts a user. The first user ever created adopts all sessions // that existed before accounts were introduced (user_id 0). func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) { return s.CreateUserWithInvite(ctx, username, passwordHash, "") } // CreateUserWithInvite creates a user and, if code is non-empty, consumes one // use of that invite code in the same transaction (all or nothing). func (s *Store) CreateUserWithInvite(ctx context.Context, username, passwordHash, code string) (User, error) { now := time.Now().UTC() tx, err := s.db.BeginTx(ctx, nil) if err != nil { return User{}, err } defer tx.Rollback() if code != "" { if err := consumeInvite(ctx, tx, code, now); err != nil { return User{}, err } } var n int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil { return User{}, err } res, err := tx.ExecContext(ctx, `INSERT INTO users(username,password_hash,created_at) VALUES(?,?,?)`, username, passwordHash, now.UnixMilli()) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { return User{}, ErrUserExists } return User{}, err } id, _ := res.LastInsertId() if code != "" { if _, err := tx.ExecContext(ctx, `INSERT INTO invite_uses(code,user_id,username,used_at) VALUES(?,?,?,?)`, code, id, username, now.UnixMilli()); err != nil { return User{}, err } } if n == 0 { if _, err := tx.ExecContext(ctx, `UPDATE sessions SET user_id=? WHERE user_id=0`, id); err != nil { return User{}, err } } return User{ID: id, Username: username, CreatedAt: now, hash: passwordHash}, tx.Commit() } func (s *Store) UserByName(ctx context.Context, username string) (User, error) { var u User var c int64 err := s.db.QueryRowContext(ctx, `SELECT id,username,password_hash,created_at FROM users WHERE username=?`, username).Scan(&u.ID, &u.Username, &u.hash, &c) if errors.Is(err, sql.ErrNoRows) { return u, ErrNotFound } u.CreatedAt = time.UnixMilli(c).UTC() return u, err } func hashToken(t string) string { h := sha256.Sum256([]byte(t)) return hex.EncodeToString(h[:]) } // IssueToken creates a random opaque token; only its SHA-256 is stored. func (s *Store) IssueToken(ctx context.Context, userID int64, ttl time.Duration) (string, time.Time, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", time.Time{}, err } tok := "bc_" + base64.RawURLEncoding.EncodeToString(b) now := time.Now().UTC() exp := now.Add(ttl) _, err := s.db.ExecContext(ctx, `INSERT INTO auth_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, hashToken(tok), userID, now.UnixMilli(), exp.UnixMilli()) s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE expires_at?`, hashToken(tok), time.Now().UTC().UnixMilli()).Scan(&u.ID, &u.Username, &c) if errors.Is(err, sql.ErrNoRows) { return u, ErrNotFound } u.CreatedAt = time.UnixMilli(c).UTC() return u, err } func (s *Store) RevokeToken(ctx context.Context, tok string) error { _, err := s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE token_hash=?`, hashToken(tok)) return err } // SessionOwnedBy returns ErrNotFound unless the session exists and belongs to userID (AllUsers = any). func (s *Store) SessionOwnedBy(ctx context.Context, userID int64, id string) error { var n int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions WHERE id=? AND (?<0 OR user_id=?)`, id, userID, userID).Scan(&n) if err == nil && n == 0 { return ErrNotFound } return err }