feat: add JSON API with token auth (closes #69)
- 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
This commit is contained in:
187
internal/models/api_token.go
Normal file
187
internal/models/api_token.go
Normal file
@@ -0,0 +1,187 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user