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 }