- Add API token model with SHA-256 hashed tokens
- Add migration 006_add_api_tokens.sql
- Add Bearer token auth middleware
- Add API endpoints under /api/v1/:
- GET /whoami
- POST /tokens (create new API token)
- GET /apps (list all apps)
- POST /apps (create app)
- GET /apps/{id} (get app)
- DELETE /apps/{id} (delete app)
- POST /apps/{id}/deploy (trigger deployment)
- GET /apps/{id}/deployments (list deployments)
- Add comprehensive tests for all API endpoints
- All tests pass, zero lint issues
188 lines
4.2 KiB
Go
188 lines
4.2 KiB
Go
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
|
|
}
|