PrintToVeraeTimeStamp/internal/auth/portal_install.go

192 lines
5.9 KiB
Go

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
}