157 lines
4.0 KiB
Go
157 lines
4.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.dcrubro.com/dcrubro/hammy-backend/internal/apikey"
|
|
"git.dcrubro.com/dcrubro/hammy-backend/internal/db"
|
|
)
|
|
|
|
// runKeygen issues an API key from the command line. This is how you get the
|
|
// first key, before there is any self-service registration, and how you issue
|
|
// yourself something to test against.
|
|
//
|
|
// hammyd keygen -email [email protected] -scopes reference,callsign
|
|
func runKeygen(ctx context.Context, args []string) error {
|
|
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
|
|
|
|
email := fs.String("email", "", "owner email (required); created if new")
|
|
callsign := fs.String("callsign", "", "owner callsign (optional, uppercase)")
|
|
label := fs.String("label", "", "what this key is for, shown in listings")
|
|
scopes := fs.String("scopes", "reference", "comma-separated scopes")
|
|
tier := fs.String("tier", "default", "quota tier: default, verified, trusted, internal")
|
|
env := fs.String("env", "live", "live or test")
|
|
expires := fs.Duration("expires", 0, "optional lifetime, e.g. 720h. Zero means no expiry")
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
if *email == "" {
|
|
fs.Usage()
|
|
|
|
return errors.New("keygen: -email is required")
|
|
}
|
|
|
|
// The core.email domain requires lowercase and core.callsign requires
|
|
// uppercase. Normalise here so the CLI is forgiving and the database stays
|
|
// canonical.
|
|
normEmail := strings.ToLower(strings.TrimSpace(*email))
|
|
normCall := strings.ToUpper(strings.TrimSpace(*callsign))
|
|
|
|
dsn := os.Getenv("HAMMY_DSN")
|
|
if dsn == "" {
|
|
return errors.New("keygen: HAMMY_DSN is not set")
|
|
}
|
|
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
return fmt.Errorf("keygen: connect: %w", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
q := db.New(pool)
|
|
|
|
owner, err := q.OwnerByEmail(ctx, normEmail)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
var callsignArg any
|
|
if normCall != "" {
|
|
callsignArg = normCall
|
|
}
|
|
|
|
created, cerr := q.CreateOwner(ctx, db.CreateOwnerParams{
|
|
Email: normEmail,
|
|
Callsign: callsignArg,
|
|
})
|
|
if cerr != nil {
|
|
return fmt.Errorf("keygen: creating owner: %w", cerr)
|
|
}
|
|
|
|
owner.ID = created.ID
|
|
fmt.Printf("created owner %d for %s\n", owner.ID, normEmail)
|
|
} else if err != nil {
|
|
return fmt.Errorf("keygen: looking up owner: %w", err)
|
|
}
|
|
|
|
gen, err := apikey.Generate(apikey.Environment(*env))
|
|
if err != nil {
|
|
return fmt.Errorf("keygen: %w", err)
|
|
}
|
|
|
|
var expiresAt *time.Time
|
|
if *expires > 0 {
|
|
t := time.Now().Add(*expires)
|
|
expiresAt = &t
|
|
}
|
|
|
|
scopeList := splitScopes(*scopes)
|
|
|
|
key, err := q.CreateKey(ctx, db.CreateKeyParams{
|
|
OwnerID: owner.ID,
|
|
KeyHash: gen.Hash,
|
|
KeyPrefix: gen.Display,
|
|
Label: nullableString(*label),
|
|
Scopes: scopeList,
|
|
QuotaTier: *tier,
|
|
ExpiresAt: db.Timestamptz(expiresAt),
|
|
})
|
|
if err != nil {
|
|
// The keys_scopes_known CHECK rejects unknown scopes, which is the
|
|
// most likely failure here and worth naming.
|
|
return fmt.Errorf("keygen: creating key (check scopes against migrations/003_api.sql): %w", err)
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Println("Key issued. This is the only time it will be shown.")
|
|
fmt.Println()
|
|
fmt.Printf(" %s\n", gen.Plaintext)
|
|
fmt.Println()
|
|
fmt.Printf(" id %d\n", key.ID)
|
|
fmt.Printf(" owner %d (%s)\n", owner.ID, normEmail)
|
|
fmt.Printf(" scopes %s\n", strings.Join(scopeList, ", "))
|
|
fmt.Printf(" tier %s\n", key.QuotaTier)
|
|
|
|
if expiresAt != nil {
|
|
fmt.Printf(" expires %s\n", expiresAt.UTC().Format(time.RFC3339))
|
|
} else {
|
|
fmt.Printf(" expires never\n")
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Println("Test it with:")
|
|
fmt.Printf(" curl -H 'Authorization: Bearer %s' http://localhost:8080/v1/whoami\n", gen.Plaintext)
|
|
fmt.Println()
|
|
|
|
return nil
|
|
}
|
|
|
|
func splitScopes(s string) []string {
|
|
parts := strings.Split(s, ",")
|
|
out := make([]string, 0, len(parts))
|
|
|
|
for _, p := range parts {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func nullableString(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
|
|
return &s
|
|
}
|