Files
verdande/internal/store/apitokens.go
T

124 lines
3.4 KiB
Go
Raw Normal View History

package store
import (
"context"
"database/sql"
"errors"
"time"
"github.com/kristianwind/verdande/internal/auth"
)
type APIToken struct {
ID string
UserID string
Name string
Prefix string
CreatedAt time.Time
LastUsedAt *time.Time
ExpiresAt *time.Time
}
// CreateAPIToken returns the token in plaintext exactly once. Only its hash is
// stored, so it can never be shown again — the UI has to say so at the point of
// creation rather than offering a "reveal" that cannot work.
func (db *DB) CreateAPIToken(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *APIToken, error) {
token, prefix, err := auth.NewAPIToken()
if err != nil {
return "", nil, err
}
t := &APIToken{
ID: NewID(),
UserID: userID,
Name: name,
Prefix: prefix,
CreatedAt: time.Now().UTC(),
ExpiresAt: expiresAt,
}
var expires any
if expiresAt != nil {
expires = expiresAt.Unix()
}
_, err = db.ExecContext(ctx,
`INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.UserID, t.Name, auth.HashToken(token), t.Prefix, t.CreatedAt.Unix(), expires)
if err != nil {
return "", nil, err
}
return token, t, nil
}
// UserByAPIToken resolves a bearer token to its owner.
func (db *DB) UserByAPIToken(ctx context.Context, token string) (*User, error) {
var id, userID string
var expires sql.NullInt64
err := db.QueryRowContext(ctx,
`SELECT id, user_id, expires_at FROM api_tokens WHERE token_hash = ?`,
auth.HashToken(token)).Scan(&id, &userID, &expires)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if expires.Valid && time.Now().Unix() > expires.Int64 {
return nil, ErrNotFound
}
// last_used_at is what lets somebody delete the token they no longer recognise
// without wondering what will break. Written at most hourly: a token driving a
// sync loop would otherwise mean a write per request.
_, _ = db.ExecContext(ctx,
`UPDATE api_tokens SET last_used_at = ?
WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
time.Now().Unix(), id, time.Now().Add(-time.Hour).Unix())
return db.UserByID(ctx, userID)
}
func (db *DB) ListAPITokens(ctx context.Context, userID string) ([]APIToken, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, user_id, name, prefix, created_at, last_used_at, expires_at
FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []APIToken
for rows.Next() {
var t APIToken
var created int64
var lastUsed, expires sql.NullInt64
if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &created, &lastUsed, &expires); err != nil {
return nil, err
}
t.CreatedAt = time.Unix(created, 0).UTC()
if lastUsed.Valid {
v := time.Unix(lastUsed.Int64, 0).UTC()
t.LastUsedAt = &v
}
if expires.Valid {
v := time.Unix(expires.Int64, 0).UTC()
t.ExpiresAt = &v
}
out = append(out, t)
}
return out, rows.Err()
}
// DeleteAPIToken is scoped by user so an id from somewhere else deletes nothing.
func (db *DB) DeleteAPIToken(ctx context.Context, userID, tokenID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM api_tokens WHERE id = ? AND user_id = ?`, tokenID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}