windows-debug: elevated GUI installer with Verae key pages (sign in / create account + email code / paste / skip), hidden scripts, per-step finish page, user-scoped key save + helper scheduled task; installer registration API spec
This commit is contained in:
parent
1cb714b173
commit
6e76bc7b0e
3 changed files with 1857 additions and 61 deletions
143
internal/auth/portal.go
Normal file
143
internal/auth/portal.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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:]
|
||||
}
|
||||
192
internal/auth/portal_install.go
Normal file
192
internal/auth/portal_install.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Installer-friendly registration API (proposed; spec in
|
||||
// docs/installer-registration-api.md). When the server does not implement it
|
||||
// (GET /portal/api/install/capabilities → 404) the client falls back to the
|
||||
// endpoints the portal web client uses today (/login, /signup, /tokens), which
|
||||
// have no email-verification step.
|
||||
|
||||
// PasswordPolicy mirrors GET /portal/api/install/capabilities .passwordPolicy.
|
||||
type PasswordPolicy struct {
|
||||
MinLength int `json:"minLength"`
|
||||
RequireLetter bool `json:"requireLetter"`
|
||||
RequireDigit bool `json:"requireDigit"`
|
||||
RequireSymbol bool `json:"requireSymbol"`
|
||||
DisallowEmail bool `json:"disallowEmail"`
|
||||
}
|
||||
|
||||
// DefaultPolicy is used when the server does not publish one (conservative).
|
||||
var DefaultPolicy = PasswordPolicy{MinLength: 10, RequireLetter: true, RequireDigit: true, DisallowEmail: true}
|
||||
|
||||
// Capabilities describes what the server supports for installers.
|
||||
type Capabilities struct {
|
||||
InstallAPI bool
|
||||
EmailVerification bool
|
||||
Policy PasswordPolicy
|
||||
}
|
||||
|
||||
// Describe renders the policy for UI text.
|
||||
func (p PasswordPolicy) Describe() string {
|
||||
parts := []string{fmt.Sprintf("at least %d characters", p.MinLength)}
|
||||
if p.RequireLetter {
|
||||
parts = append(parts, "a letter")
|
||||
}
|
||||
if p.RequireDigit {
|
||||
parts = append(parts, "a number")
|
||||
}
|
||||
if p.RequireSymbol {
|
||||
parts = append(parts, "a symbol")
|
||||
}
|
||||
if p.DisallowEmail {
|
||||
parts = append(parts, "not containing your email")
|
||||
}
|
||||
return "Password: " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// Check validates a password client-side.
|
||||
func (p PasswordPolicy) Check(pw, email string) error {
|
||||
if len([]rune(pw)) < p.MinLength {
|
||||
return fmt.Errorf("password must be at least %d characters", p.MinLength)
|
||||
}
|
||||
var l, d, s bool
|
||||
for _, r := range pw {
|
||||
switch {
|
||||
case unicode.IsLetter(r):
|
||||
l = true
|
||||
case unicode.IsDigit(r):
|
||||
d = true
|
||||
case !unicode.IsSpace(r):
|
||||
s = true
|
||||
}
|
||||
}
|
||||
if p.RequireLetter && !l {
|
||||
return errors.New("password must contain a letter")
|
||||
}
|
||||
if p.RequireDigit && !d {
|
||||
return errors.New("password must contain a number")
|
||||
}
|
||||
if p.RequireSymbol && !s {
|
||||
return errors.New("password must contain a symbol")
|
||||
}
|
||||
if p.DisallowEmail && email != "" {
|
||||
local := strings.ToLower(strings.SplitN(email, "@", 2)[0])
|
||||
if len(local) >= 3 && strings.Contains(strings.ToLower(pw), local) {
|
||||
return errors.New("password must not contain your email name")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
||||
|
||||
// ValidEmail is a light syntax check.
|
||||
func ValidEmail(e string) bool { return emailRe.MatchString(strings.TrimSpace(e)) }
|
||||
|
||||
// MaskEmail renders g***@example.com for logs.
|
||||
func MaskEmail(e string) string {
|
||||
at := strings.LastIndex(e, "@")
|
||||
if at < 1 {
|
||||
return "***"
|
||||
}
|
||||
return e[:1] + "***" + e[at:]
|
||||
}
|
||||
|
||||
// GetCapabilities probes GET /portal/api/install/capabilities.
|
||||
func GetCapabilities() Capabilities {
|
||||
c := Capabilities{Policy: DefaultPolicy}
|
||||
req, _ := http.NewRequest(http.MethodGet, PortalBase()+"/portal/api/install/capabilities", nil)
|
||||
req.Header.Set("User-Agent", "VeraePrintStamp-Installer")
|
||||
res, err := portalHTTP.Do(req)
|
||||
if err != nil {
|
||||
return c
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 || !strings.Contains(res.Header.Get("Content-Type"), "json") {
|
||||
return c
|
||||
}
|
||||
var body struct {
|
||||
InstallAPI bool `json:"installApi"`
|
||||
EmailVerification bool `json:"emailVerification"`
|
||||
PasswordPolicy *PasswordPolicy `json:"passwordPolicy"`
|
||||
}
|
||||
if err := decodeJSON(res, &body); err != nil {
|
||||
return c
|
||||
}
|
||||
c.InstallAPI, c.EmailVerification = body.InstallAPI, body.EmailVerification
|
||||
if body.PasswordPolicy != nil && body.PasswordPolicy.MinLength > 0 {
|
||||
c.Policy = *body.PasswordPolicy
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// RegisterResult is returned by InstallRegister.
|
||||
type RegisterResult struct {
|
||||
PendingID string // non-empty → email code sent; call InstallVerify
|
||||
Key string // non-empty → account created and key minted (fallback path)
|
||||
}
|
||||
|
||||
// InstallRegister starts a new-account registration.
|
||||
func InstallRegister(caps Capabilities, name, email, password, tokenName string) (RegisterResult, error) {
|
||||
if !caps.InstallAPI {
|
||||
// Fallback: current portal API creates the account immediately (no email code).
|
||||
k, err := PortalMintKey(true, name, email, password, "", tokenName)
|
||||
return RegisterResult{Key: k}, err
|
||||
}
|
||||
out, _, err := portalPost("/install/register", "", map[string]any{"name": name, "email": email, "password": password, "client": "VeraePrintStamp-Windows"})
|
||||
if err != nil {
|
||||
return RegisterResult{}, err
|
||||
}
|
||||
id, _ := out["pendingId"].(string)
|
||||
if id == "" {
|
||||
return RegisterResult{}, errors.New("register: server returned no pendingId")
|
||||
}
|
||||
return RegisterResult{PendingID: id}, nil
|
||||
}
|
||||
|
||||
// InstallVerify submits the emailed code and returns the minted key.
|
||||
func InstallVerify(pendingID, code, tokenName string) (string, error) {
|
||||
out, _, err := portalPost("/install/verify", "", map[string]any{"pendingId": pendingID, "code": strings.TrimSpace(code), "tokenName": tokenName})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return keyFrom(out)
|
||||
}
|
||||
|
||||
// InstallResend asks for a new code.
|
||||
func InstallResend(pendingID string) error {
|
||||
_, _, err := portalPost("/install/resend", "", map[string]any{"pendingId": pendingID})
|
||||
return err
|
||||
}
|
||||
|
||||
// InstallLogin signs in and mints a key (install API or fallback).
|
||||
func InstallLogin(caps Capabilities, email, password, totp, tokenName string) (string, error) {
|
||||
if !caps.InstallAPI {
|
||||
return PortalMintKey(false, "", email, password, totp, tokenName)
|
||||
}
|
||||
body := map[string]any{"email": email, "password": password, "tokenName": tokenName}
|
||||
if totp != "" {
|
||||
body["totpCode"] = totp
|
||||
}
|
||||
out, _, err := portalPost("/install/login", "", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return keyFrom(out)
|
||||
}
|
||||
|
||||
func keyFrom(out map[string]any) (string, error) {
|
||||
k, _ := out["token"].(string)
|
||||
if !strings.HasPrefix(k, "xVerae-") && !strings.HasPrefix(k, "key-") {
|
||||
return "", errors.New("server returned no xVerae- token")
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
116
internal/auth/portal_test.go
Normal file
116
internal/auth/portal_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fakePortal(t *testing.T, installAPI bool) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
js := func(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
body := func(r *http.Request) map[string]any {
|
||||
m := map[string]any{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&m)
|
||||
return m
|
||||
}
|
||||
if installAPI {
|
||||
mux.HandleFunc("/portal/api/install/capabilities", func(w http.ResponseWriter, r *http.Request) {
|
||||
js(w, 200, map[string]any{"installApi": true, "emailVerification": true, "passwordPolicy": map[string]any{"minLength": 12, "requireLetter": true, "requireDigit": true}})
|
||||
})
|
||||
mux.HandleFunc("/portal/api/install/register", func(w http.ResponseWriter, r *http.Request) { js(w, 202, map[string]any{"pendingId": "p1"}) })
|
||||
mux.HandleFunc("/portal/api/install/verify", func(w http.ResponseWriter, r *http.Request) {
|
||||
if body(r)["code"] != "123456" {
|
||||
js(w, 400, map[string]any{"error": "invalid_code"})
|
||||
return
|
||||
}
|
||||
js(w, 201, map[string]any{"token": "xVerae-verified-abcdefgh"})
|
||||
})
|
||||
mux.HandleFunc("/portal/api/install/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
b := body(r)
|
||||
if b["totpCode"] == nil {
|
||||
js(w, 401, map[string]any{"error": "totp_required"})
|
||||
return
|
||||
}
|
||||
js(w, 200, map[string]any{"token": "xVerae-login-abcdefgh"})
|
||||
})
|
||||
}
|
||||
mux.HandleFunc("/portal/api/login", func(w http.ResponseWriter, r *http.Request) { js(w, 200, map[string]any{"token": "sess"}) })
|
||||
mux.HandleFunc("/portal/api/signup", func(w http.ResponseWriter, r *http.Request) { js(w, 200, map[string]any{"token": "sess"}) })
|
||||
mux.HandleFunc("/portal/api/tokens", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer sess" {
|
||||
js(w, 401, map[string]any{"error": "no"})
|
||||
return
|
||||
}
|
||||
js(w, 200, map[string]any{"token": "xVerae-minted-abcdefgh"})
|
||||
})
|
||||
mux.HandleFunc("/portal/api/logout", func(w http.ResponseWriter, r *http.Request) { js(w, 200, map[string]any{}) })
|
||||
s := httptest.NewServer(mux)
|
||||
t.Cleanup(s.Close)
|
||||
t.Setenv("VERAE_PORTAL_BASE", s.URL)
|
||||
t.Setenv("VERAE_ALLOW_INSECURE_PORTAL", "1")
|
||||
return s
|
||||
}
|
||||
|
||||
func TestPortalFallback(t *testing.T) {
|
||||
fakePortal(t, false)
|
||||
caps := GetCapabilities()
|
||||
if caps.InstallAPI || caps.Policy.MinLength != DefaultPolicy.MinLength {
|
||||
t.Fatalf("caps %+v", caps)
|
||||
}
|
||||
k, err := InstallLogin(caps, "a@b.co", "pw", "", "n")
|
||||
if err != nil || k != "xVerae-minted-abcdefgh" {
|
||||
t.Fatal(k, err)
|
||||
}
|
||||
rr, err := InstallRegister(caps, "N", "a@b.co", "pw", "n")
|
||||
if err != nil || rr.Key == "" || rr.PendingID != "" {
|
||||
t.Fatal(rr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortalInstallAPI(t *testing.T) {
|
||||
fakePortal(t, true)
|
||||
caps := GetCapabilities()
|
||||
if !caps.InstallAPI || caps.Policy.MinLength != 12 {
|
||||
t.Fatalf("caps %+v", caps)
|
||||
}
|
||||
if _, err := InstallLogin(caps, "a@b.co", "pw", "", "n"); !errors.Is(err, ErrTOTPRequired) {
|
||||
t.Fatal("want totp_required", err)
|
||||
}
|
||||
if k, err := InstallLogin(caps, "a@b.co", "pw", "123456", "n"); err != nil || k != "xVerae-login-abcdefgh" {
|
||||
t.Fatal(k, err)
|
||||
}
|
||||
rr, err := InstallRegister(caps, "N", "a@b.co", "pw", "n")
|
||||
if err != nil || rr.PendingID != "p1" {
|
||||
t.Fatal(rr, err)
|
||||
}
|
||||
if _, err := InstallVerify("p1", "000000", "n"); err == nil {
|
||||
t.Fatal("bad code accepted")
|
||||
}
|
||||
if k, err := InstallVerify("p1", "123456", "n"); err != nil || k != "xVerae-verified-abcdefgh" {
|
||||
t.Fatal(k, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyAndHTTPS(t *testing.T) {
|
||||
p := DefaultPolicy
|
||||
for pw, ok := range map[string]bool{"short1": false, "longenoughnodigit": false, "longenough123": true, "george12345x": false} {
|
||||
if (p.Check(pw, "george@x.com") == nil) != ok {
|
||||
t.Errorf("policy %q want ok=%v", pw, ok)
|
||||
}
|
||||
}
|
||||
t.Setenv("VERAE_PORTAL_BASE", "http://example.com")
|
||||
t.Setenv("VERAE_ALLOW_INSECURE_PORTAL", "")
|
||||
if _, _, err := portalPost("/login", "", map[string]any{}); err == nil {
|
||||
t.Fatal("non-https portal allowed")
|
||||
}
|
||||
if MaskKey("xVerae-abcdefghijklmnop") != "xVerae-...mnop" || MaskEmail("george@x.com") != "g***@x.com" {
|
||||
t.Fatal("masking")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue