package models import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "errors" "fmt" "time" "git.eeqj.de/sneak/upaas/internal/database" ) // tokenBytes is the number of random bytes for a raw API token. const tokenBytes = 32 // APIToken represents an API authentication token. type APIToken struct { db *database.Database ID int64 UserID int64 Name string TokenHash string CreatedAt time.Time LastUsedAt sql.NullTime } // NewAPIToken creates a new APIToken with a database reference. func NewAPIToken(db *database.Database) *APIToken { return &APIToken{db: db} } // GenerateAPIToken creates a new API token for a user, returning the raw token // string (shown once) and the persisted APIToken record. func GenerateAPIToken( ctx context.Context, db *database.Database, userID int64, name string, ) (string, *APIToken, error) { raw := make([]byte, tokenBytes) _, err := rand.Read(raw) if err != nil { return "", nil, fmt.Errorf("generating token bytes: %w", err) } rawHex := hex.EncodeToString(raw) hash := HashAPIToken(rawHex) token := NewAPIToken(db) token.UserID = userID token.Name = name token.TokenHash = hash query := `INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, ?, ?)` result, execErr := db.Exec(ctx, query, userID, name, hash) if execErr != nil { return "", nil, fmt.Errorf("inserting api token: %w", execErr) } id, idErr := result.LastInsertId() if idErr != nil { return "", nil, fmt.Errorf("getting token id: %w", idErr) } token.ID = id reloadErr := token.Reload(ctx) if reloadErr != nil { return "", nil, fmt.Errorf("reloading token: %w", reloadErr) } return rawHex, token, nil } // HashAPIToken returns the SHA-256 hex digest of a raw token string. func HashAPIToken(raw string) string { sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:]) } // Reload refreshes the token from the database. func (t *APIToken) Reload(ctx context.Context) error { row := t.db.QueryRow(ctx, `SELECT id, user_id, name, token_hash, created_at, last_used_at FROM api_tokens WHERE id = ?`, t.ID, ) return t.scan(row) } // Delete removes the token from the database. func (t *APIToken) Delete(ctx context.Context) error { _, err := t.db.Exec(ctx, "DELETE FROM api_tokens WHERE id = ?", t.ID) return err } // TouchLastUsed updates the last_used_at timestamp. func (t *APIToken) TouchLastUsed(ctx context.Context) error { _, err := t.db.Exec(ctx, "UPDATE api_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?", t.ID, ) return err } func (t *APIToken) scan(row *sql.Row) error { return row.Scan( &t.ID, &t.UserID, &t.Name, &t.TokenHash, &t.CreatedAt, &t.LastUsedAt, ) } // FindAPITokenByHash looks up a token by its SHA-256 hash. // //nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record func FindAPITokenByHash( ctx context.Context, db *database.Database, hash string, ) (*APIToken, error) { token := NewAPIToken(db) row := db.QueryRow(ctx, `SELECT id, user_id, name, token_hash, created_at, last_used_at FROM api_tokens WHERE token_hash = ?`, hash, ) err := token.scan(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("scanning api token: %w", err) } return token, nil } // FindAPITokensByUserID returns all tokens for a user. func FindAPITokensByUserID( ctx context.Context, db *database.Database, userID int64, ) ([]*APIToken, error) { rows, err := db.Query(ctx, `SELECT id, user_id, name, token_hash, created_at, last_used_at FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID, ) if err != nil { return nil, fmt.Errorf("querying api tokens: %w", err) } defer func() { _ = rows.Close() }() var tokens []*APIToken for rows.Next() { tok := NewAPIToken(db) scanErr := rows.Scan( &tok.ID, &tok.UserID, &tok.Name, &tok.TokenHash, &tok.CreatedAt, &tok.LastUsedAt, ) if scanErr != nil { return nil, fmt.Errorf("scanning api token row: %w", scanErr) } tokens = append(tokens, tok) } rowsErr := rows.Err() if rowsErr != nil { return nil, fmt.Errorf("iterating api token rows: %w", rowsErr) } return tokens, nil }