192 lines
5.9 KiB
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
|
|
}
|