112 lines
3.6 KiB
Go
112 lines
3.6 KiB
Go
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) {
|
|
now := time.Now().UTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
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 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<?`, now.UnixMilli())
|
|
return tok, exp, err
|
|
}
|
|
|
|
// UserByToken resolves a non-expired token (lookup by hash, so no timing leak on the secret).
|
|
func (s *Store) UserByToken(ctx context.Context, tok string) (User, error) {
|
|
var u User
|
|
var c int64
|
|
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.created_at FROM auth_tokens t JOIN users u ON u.id=t.user_id
|
|
WHERE t.token_hash=? AND t.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
|
|
}
|