143 lines
4.5 KiB
Go
143 lines
4.5 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Portal account API — the same JSON endpoints the customer.verae.ai/portal web
|
|
// client (portal/app.js) uses: POST /portal/api/login|signup → {token: session},
|
|
// POST /portal/api/tokens {name} (Bearer session) → {token: "xVerae-…"},
|
|
// POST /portal/api/logout. There is no device-code / email-code flow, so the
|
|
// installer uses email+password (+ optional authenticator code) and mints a key.
|
|
|
|
// PortalBase is overridable for testing via VERAE_PORTAL_BASE.
|
|
func PortalBase() string {
|
|
if v := strings.TrimRight(os.Getenv("VERAE_PORTAL_BASE"), "/"); v != "" {
|
|
return v
|
|
}
|
|
return "https://customer.verae.ai"
|
|
}
|
|
|
|
// ErrTOTPRequired means the account has 2FA and a 6-digit code is needed.
|
|
var ErrTOTPRequired = fmt.Errorf("totp_required")
|
|
|
|
var portalHTTP = &http.Client{Timeout: 20 * time.Second}
|
|
|
|
func decodeJSON(res *http.Response, v any) error {
|
|
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(v)
|
|
}
|
|
|
|
func portalPost(path, bearer string, body any) (map[string]any, int, error) {
|
|
if !strings.HasPrefix(PortalBase(), "https://") && os.Getenv("VERAE_ALLOW_INSECURE_PORTAL") != "1" {
|
|
return nil, 0, fmt.Errorf("refusing non-HTTPS portal base %q", PortalBase())
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
req, err := http.NewRequest(http.MethodPost, PortalBase()+"/portal/api"+path, bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "VeraePrintStamp-Installer")
|
|
if bearer != "" {
|
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
|
}
|
|
res, err := portalHTTP.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer res.Body.Close()
|
|
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
|
out := map[string]any{}
|
|
_ = json.Unmarshal(raw, &out)
|
|
if res.StatusCode >= 300 {
|
|
msg, _ := out["error"].(string)
|
|
if msg == "totp_required" {
|
|
return out, res.StatusCode, ErrTOTPRequired
|
|
}
|
|
if msg == "" {
|
|
msg = strings.TrimSpace(string(raw))
|
|
if len(msg) > 200 {
|
|
msg = msg[:200]
|
|
}
|
|
}
|
|
return out, res.StatusCode, fmt.Errorf("HTTP %d: %s", res.StatusCode, msg)
|
|
}
|
|
return out, res.StatusCode, nil
|
|
}
|
|
|
|
// PortalMintKey signs in (or signs up), mints a long-lived xVerae- token named
|
|
// tokenName, and signs out. Credentials are never stored.
|
|
func PortalMintKey(signup bool, name, email, password, totp, tokenName string) (string, error) {
|
|
var body map[string]any
|
|
path := "/login"
|
|
if signup {
|
|
path = "/signup"
|
|
body = map[string]any{"name": name, "email": email, "password": password}
|
|
} else {
|
|
body = map[string]any{"email": email, "password": password}
|
|
if totp != "" {
|
|
body["totpCode"] = totp
|
|
}
|
|
}
|
|
out, _, err := portalPost(path, "", body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
session, _ := out["token"].(string)
|
|
if session == "" {
|
|
return "", fmt.Errorf("portal %s returned no session", path)
|
|
}
|
|
defer func() { _, _, _ = portalPost("/logout", session, map[string]any{}) }()
|
|
tk, _, err := portalPost("/tokens", session, map[string]any{"name": tokenName})
|
|
if err != nil {
|
|
return "", fmt.Errorf("mint key: %w", err)
|
|
}
|
|
key, _ := tk["token"].(string)
|
|
if !strings.HasPrefix(key, "xVerae-") && !strings.HasPrefix(key, "key-") {
|
|
return "", fmt.Errorf("mint key: unexpected response (no xVerae- token)")
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// ValidateXVeraeKey checks a key against zap.verae.ai GET /v1/status (documented
|
|
// as free). Returns "valid", "invalid", "badformat", "unreachable" or "unknown".
|
|
func ValidateXVeraeKey(key string) (string, string) {
|
|
key = strings.TrimSpace(key)
|
|
if !(strings.HasPrefix(key, "xVerae-") || strings.HasPrefix(key, "key-")) || len(key) < 16 || strings.ContainsAny(key, " \t\r\n") {
|
|
return "badformat", "must start with xVerae- (or legacy key-)"
|
|
}
|
|
req, _ := http.NewRequest(http.MethodGet, "https://zap.verae.ai/v1/status", nil)
|
|
req.Header.Set("x-api-key", key)
|
|
res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return "unreachable", err.Error()
|
|
}
|
|
res.Body.Close()
|
|
switch {
|
|
case res.StatusCode < 300:
|
|
return "valid", fmt.Sprintf("HTTP %d", res.StatusCode)
|
|
case res.StatusCode == 401 || res.StatusCode == 403:
|
|
return "invalid", fmt.Sprintf("HTTP %d from Verae", res.StatusCode)
|
|
default:
|
|
return "unknown", fmt.Sprintf("HTTP %d from Verae", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
// MaskKey renders xVerae-...abcd.
|
|
func MaskKey(k string) string {
|
|
k = strings.TrimSpace(k)
|
|
if len(k) <= 12 {
|
|
if len(k) > 4 {
|
|
return k[:4] + "..."
|
|
}
|
|
return "..."
|
|
}
|
|
return k[:7] + "..." + k[len(k)-4:]
|
|
}
|