| 1 |
package app
|
| 2 |
|
| 3 |
import (
|
| 4 |
"context"
|
| 5 |
"errors"
|
| 6 |
"time"
|
| 7 |
|
| 8 |
"github.com/jackc/pgx/v5"
|
| 9 |
"golang.org/x/crypto/bcrypt"
|
| 10 |
)
|
| 11 |
|
| 12 |
const (
|
| 13 |
inviteTTL = 7 * 24 * time.Hour
|
| 14 |
resetTTL = 24 * time.Hour
|
| 15 |
)
|
| 16 |
|
| 17 |
var errBadToken = errors.New("this link is invalid or has expired")
|
| 18 |
|
| 19 |
// issueToken creates a one-time token for a user and returns the raw value to
|
| 20 |
// embed in a link. Any prior unused token of the same purpose is dropped.
|
| 21 |
func (a *App) issueToken(ctx context.Context, userID int64, purpose string, ttl time.Duration) (string, error) {
|
| 22 |
raw := randomToken()
|
| 23 |
_, err := a.pool.Exec(ctx,
|
| 24 |
`DELETE FROM user_tokens WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL`,
|
| 25 |
userID, purpose)
|
| 26 |
if err != nil {
|
| 27 |
return "", err
|
| 28 |
}
|
| 29 |
_, err = a.pool.Exec(ctx,
|
| 30 |
`INSERT INTO user_tokens (token_hash, user_id, purpose, expires_at) VALUES ($1, $2, $3, $4)`,
|
| 31 |
hashToken(raw), userID, purpose, time.Now().Add(ttl))
|
| 32 |
if err != nil {
|
| 33 |
return "", err
|
| 34 |
}
|
| 35 |
return raw, nil
|
| 36 |
}
|
| 37 |
|
| 38 |
// peekToken validates a token without consuming it (for rendering the form).
|
| 39 |
func (a *App) peekToken(ctx context.Context, raw string) error {
|
| 40 |
if raw == "" {
|
| 41 |
return errBadToken
|
| 42 |
}
|
| 43 |
var n int
|
| 44 |
err := a.pool.QueryRow(ctx,
|
| 45 |
`SELECT 1 FROM user_tokens WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`,
|
| 46 |
hashToken(raw)).Scan(&n)
|
| 47 |
if errors.Is(err, pgx.ErrNoRows) {
|
| 48 |
return errBadToken
|
| 49 |
}
|
| 50 |
return err
|
| 51 |
}
|
| 52 |
|
| 53 |
// consumeToken validates a token, marks it used, and returns the user id.
|
| 54 |
func (a *App) consumeToken(ctx context.Context, raw string) (int64, error) {
|
| 55 |
if raw == "" {
|
| 56 |
return 0, errBadToken
|
| 57 |
}
|
| 58 |
var userID int64
|
| 59 |
err := a.pool.QueryRow(ctx,
|
| 60 |
`UPDATE user_tokens SET used_at = now()
|
| 61 |
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
|
| 62 |
RETURNING user_id`,
|
| 63 |
hashToken(raw)).Scan(&userID)
|
| 64 |
if errors.Is(err, pgx.ErrNoRows) {
|
| 65 |
return 0, errBadToken
|
| 66 |
}
|
| 67 |
return userID, err
|
| 68 |
}
|
| 69 |
|
| 70 |
// setPassword updates a user's password hash.
|
| 71 |
func (a *App) setPassword(ctx context.Context, userID int64, password string) error {
|
| 72 |
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
| 73 |
if err != nil {
|
| 74 |
return err
|
| 75 |
}
|
| 76 |
_, err = a.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, string(hash), userID)
|
| 77 |
return err
|
| 78 |
}
|
| 79 |
|
| 80 |
func (a *App) subscriberForUser(ctx context.Context, userID int64) (Subscriber, error) {
|
| 81 |
var subID int64
|
| 82 |
if err := a.pool.QueryRow(ctx, `SELECT subscriber_id FROM users WHERE id = $1`, userID).Scan(&subID); err != nil {
|
| 83 |
return Subscriber{}, err
|
| 84 |
}
|
| 85 |
return a.subscriber(ctx, subID)
|
| 86 |
}
|