382 lines
11 KiB
Go
382 lines
11 KiB
Go
// Package installapi is a reference implementation of the installer
|
|
// registration API (docs/installer-registration-api.md), meant to be mounted
|
|
// by the portal backend under /portal/api. Account storage is behind the
|
|
// Accounts interface so the real portal database can be plugged in.
|
|
//
|
|
// Secrets: passwords are hashed by Accounts.HashPassword during /register and
|
|
// the plaintext is never retained or logged; codes are stored only as salted
|
|
// SHA-256 and compared in constant time; logs carry masked emails only.
|
|
package installapi
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/mail"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidCredentials = errors.New("invalid_credentials")
|
|
ErrTOTPRequired = errors.New("totp_required")
|
|
ErrTOTPInvalid = errors.New("totp_invalid")
|
|
)
|
|
|
|
// Accounts is implemented by the portal backend.
|
|
type Accounts interface {
|
|
EmailExists(email string) (bool, error)
|
|
HashPassword(password string) (string, error)
|
|
CreateAccount(name, email, passwordHash string) (userID string, err error)
|
|
Authenticate(email, password, totp string) (userID string, err error)
|
|
MintToken(userID, name string, client map[string]string) (token string, err error)
|
|
}
|
|
|
|
// Policy mirrors capabilities.passwordPolicy.
|
|
type Policy struct {
|
|
MinLength int `json:"minLength"`
|
|
RequireLetter bool `json:"requireLetter"`
|
|
RequireDigit bool `json:"requireDigit"`
|
|
RequireSymbol bool `json:"requireSymbol"`
|
|
DisallowEmail bool `json:"disallowEmail"`
|
|
}
|
|
|
|
var DefaultPolicy = Policy{MinLength: 10, RequireLetter: true, RequireDigit: true, DisallowEmail: true}
|
|
|
|
func (p Policy) check(pw, email string) string {
|
|
if len([]rune(pw)) < p.MinLength {
|
|
return "Password must be at least " + itoa(p.MinLength) + " characters."
|
|
}
|
|
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
|
|
}
|
|
}
|
|
switch {
|
|
case p.RequireLetter && !l:
|
|
return "Password must contain a letter."
|
|
case p.RequireDigit && !d:
|
|
return "Password must contain a number."
|
|
case p.RequireSymbol && !s:
|
|
return "Password must contain a symbol."
|
|
}
|
|
if p.DisallowEmail {
|
|
if i := strings.Index(email, "@"); i >= 3 && strings.Contains(strings.ToLower(pw), strings.ToLower(email[:i])) {
|
|
return "Password must not contain your email address."
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func itoa(n int) string { return strconv.Itoa(n) }
|
|
|
|
type pending struct {
|
|
name, email, hash string
|
|
exists bool
|
|
salt []byte
|
|
codeHash [32]byte
|
|
expires time.Time
|
|
attempts int
|
|
}
|
|
|
|
// Server holds the API state.
|
|
type Server struct {
|
|
Accounts Accounts
|
|
Mail mailer.Sender
|
|
Limiter *mailer.Limiter
|
|
Policy Policy
|
|
CodeTTL time.Duration
|
|
MaxAttempt int
|
|
TrustProxy bool // honour X-Forwarded-For (only behind your own proxy)
|
|
Log func(format string, args ...any)
|
|
Now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
pending map[string]*pending
|
|
loginRL *mailer.Limiter
|
|
}
|
|
|
|
func New(a Accounts, m mailer.Sender) *Server {
|
|
return &Server{Accounts: a, Mail: m, Limiter: mailer.NewLimiter(mailer.DefaultLimits), Policy: DefaultPolicy,
|
|
CodeTTL: 15 * time.Minute, MaxAttempt: 5, Log: func(string, ...any) {}, Now: time.Now,
|
|
pending: map[string]*pending{},
|
|
loginRL: mailer.NewLimiter(mailer.Limits{PerEmailHour: 20, PerIPHour: 60})}
|
|
}
|
|
|
|
// Handler serves /install/* (mount under /portal/api).
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/install/capabilities", s.capabilities)
|
|
mux.HandleFunc("/install/register", s.post(s.register))
|
|
mux.HandleFunc("/install/verify", s.post(s.verify))
|
|
mux.HandleFunc("/install/resend", s.post(s.resend))
|
|
mux.HandleFunc("/install/login", s.post(s.login))
|
|
return mux
|
|
}
|
|
|
|
type apiErr struct {
|
|
status int
|
|
body map[string]any
|
|
}
|
|
|
|
func fail(status int, code, msg string) *apiErr {
|
|
return &apiErr{status, map[string]any{"error": code, "message": msg}}
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func (s *Server) post(fn func(r *http.Request, body map[string]any) (int, any, *apiErr)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, 405, map[string]any{"error": "method_not_allowed"})
|
|
return
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&body); err != nil {
|
|
writeJSON(w, 400, map[string]any{"error": "bad_json", "message": "Invalid request."})
|
|
return
|
|
}
|
|
st, out, e := fn(r, body)
|
|
if e != nil {
|
|
writeJSON(w, e.status, e.body)
|
|
return
|
|
}
|
|
writeJSON(w, st, out)
|
|
}
|
|
}
|
|
|
|
func str(b map[string]any, k string) string { v, _ := b[k].(string); return v }
|
|
|
|
func (s *Server) ip(r *http.Request) string {
|
|
if s.TrustProxy {
|
|
if f := r.Header.Get("X-Forwarded-For"); f != "" {
|
|
return strings.TrimSpace(strings.Split(f, ",")[0])
|
|
}
|
|
}
|
|
h, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return h
|
|
}
|
|
|
|
func limited(err error) *apiErr {
|
|
var le *mailer.LimitError
|
|
if errors.As(err, &le) {
|
|
e := fail(429, "rate_limited", "Too many attempts. Please wait and try again.")
|
|
e.body["retryAfterSeconds"] = int(le.RetryAfter.Seconds() + 0.999)
|
|
return e
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) capabilities(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, map[string]any{"installApi": true, "emailVerification": true, "passwordPolicy": s.Policy})
|
|
}
|
|
|
|
func newID() string {
|
|
b := make([]byte, 24)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func hashCode(salt []byte, code string) [32]byte {
|
|
return sha256.Sum256(append(append([]byte{}, salt...), []byte(code)...))
|
|
}
|
|
|
|
func normEmail(e string) (string, bool) {
|
|
e = strings.TrimSpace(e)
|
|
a, err := mail.ParseAddress(e)
|
|
if err != nil || a.Address != e || len(e) > 254 || strings.ContainsAny(e, "\r\n") {
|
|
return "", false
|
|
}
|
|
return strings.ToLower(e), true
|
|
}
|
|
|
|
// issueCode creates/refreshes a code for p and emails it.
|
|
func (s *Server) issueCode(p *pending, ip string) *apiErr {
|
|
if err := s.Limiter.Allow(p.email, ip); err != nil {
|
|
s.Log("install: code email to %s rate-limited: %v", mailer.MaskEmail(p.email), err)
|
|
return limited(err)
|
|
}
|
|
code, err := mailer.NewCode()
|
|
if err != nil {
|
|
return fail(500, "internal", "Please try again.")
|
|
}
|
|
p.salt = []byte(newID())
|
|
p.codeHash = hashCode(p.salt, code)
|
|
p.expires = s.Now().Add(s.CodeTTL)
|
|
p.attempts = 0
|
|
var m mailer.Message
|
|
if p.exists {
|
|
m = mailer.AccountExistsEmail(p.email)
|
|
} else {
|
|
m = mailer.CodeEmail(p.email, p.name, code, int(s.CodeTTL.Minutes()), "the Verae installer")
|
|
}
|
|
if err := s.Mail.Send(m); err != nil {
|
|
s.Log("install: send to %s failed: %v", mailer.MaskEmail(p.email), err)
|
|
return fail(502, "email_failed", "We could not send the email. Please try again later.")
|
|
}
|
|
s.Log("install: code email sent to %s (exists=%v)", mailer.MaskEmail(p.email), p.exists)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) register(r *http.Request, b map[string]any) (int, any, *apiErr) {
|
|
name := strings.TrimSpace(str(b, "name"))
|
|
email, ok := normEmail(str(b, "email"))
|
|
pw := str(b, "password")
|
|
if name == "" || len(name) > 200 {
|
|
return 0, nil, fail(400, "name_required", "Please enter your name.")
|
|
}
|
|
if !ok {
|
|
return 0, nil, fail(400, "invalid_email", "Please enter a valid email address.")
|
|
}
|
|
if msg := s.Policy.check(pw, email); msg != "" {
|
|
return 0, nil, fail(400, "weak_password", msg)
|
|
}
|
|
exists, err := s.Accounts.EmailExists(email)
|
|
if err != nil {
|
|
return 0, nil, fail(500, "internal", "Please try again.")
|
|
}
|
|
p := &pending{name: name, email: email, exists: exists}
|
|
if !exists {
|
|
if p.hash, err = s.Accounts.HashPassword(pw); err != nil {
|
|
return 0, nil, fail(500, "internal", "Please try again.")
|
|
}
|
|
}
|
|
pw = ""
|
|
if e := s.issueCode(p, s.ip(r)); e != nil {
|
|
return 0, nil, e
|
|
}
|
|
id := newID()
|
|
s.mu.Lock()
|
|
s.gc()
|
|
s.pending[id] = p
|
|
s.mu.Unlock()
|
|
return 202, map[string]any{"pendingId": id, "expiresInSeconds": int(s.CodeTTL.Seconds()), "resendAfterSeconds": int(s.Limiter.L.Cooldown.Seconds())}, nil
|
|
}
|
|
|
|
func (s *Server) gc() {
|
|
now := s.Now()
|
|
for k, p := range s.pending {
|
|
if now.After(p.expires.Add(time.Hour)) {
|
|
delete(s.pending, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) resend(r *http.Request, b map[string]any) (int, any, *apiErr) {
|
|
s.mu.Lock()
|
|
p := s.pending[str(b, "pendingId")]
|
|
s.mu.Unlock()
|
|
if p == nil {
|
|
return 0, nil, fail(410, "expired", "This sign-up has expired. Please start again.")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if e := s.issueCode(p, s.ip(r)); e != nil {
|
|
return 0, nil, e
|
|
}
|
|
return 202, map[string]any{"resendAfterSeconds": int(s.Limiter.L.Cooldown.Seconds())}, nil
|
|
}
|
|
|
|
func (s *Server) verify(r *http.Request, b map[string]any) (int, any, *apiErr) {
|
|
id := str(b, "pendingId")
|
|
code := strings.ReplaceAll(strings.TrimSpace(str(b, "code")), " ", "")
|
|
s.mu.Lock()
|
|
p := s.pending[id]
|
|
if p == nil || s.Now().After(p.expires) {
|
|
s.mu.Unlock()
|
|
return 0, nil, fail(410, "expired", "This code has expired. Click 'Send a new code'.")
|
|
}
|
|
h := hashCode(p.salt, code)
|
|
if p.exists || subtle.ConstantTimeCompare(h[:], p.codeHash[:]) != 1 {
|
|
p.attempts++
|
|
left := s.MaxAttempt - p.attempts
|
|
if left <= 0 {
|
|
delete(s.pending, id)
|
|
s.mu.Unlock()
|
|
return 0, nil, fail(410, "too_many_attempts", "Too many wrong codes. Please start again.")
|
|
}
|
|
s.mu.Unlock()
|
|
e := fail(400, "invalid_code", "That code is not correct.")
|
|
e.body["attemptsLeft"] = left
|
|
return 0, nil, e
|
|
}
|
|
delete(s.pending, id) // single use
|
|
s.mu.Unlock()
|
|
uid, err := s.Accounts.CreateAccount(p.name, p.email, p.hash)
|
|
if err != nil {
|
|
return 0, nil, fail(500, "internal", "Could not create the account.")
|
|
}
|
|
tn := str(b, "tokenName")
|
|
if tn == "" {
|
|
tn = "Verae installer"
|
|
}
|
|
tok, err := s.Accounts.MintToken(uid, tn, nil)
|
|
if err != nil {
|
|
return 0, nil, fail(500, "internal", "Account created; sign in to get a key.")
|
|
}
|
|
s.Log("install: account created for %s", mailer.MaskEmail(p.email))
|
|
return 201, map[string]any{"token": tok, "name": tn}, nil
|
|
}
|
|
|
|
func (s *Server) login(r *http.Request, b map[string]any) (int, any, *apiErr) {
|
|
email, ok := normEmail(str(b, "email"))
|
|
if !ok {
|
|
return 0, nil, fail(401, "invalid_credentials", "Email or password is incorrect.")
|
|
}
|
|
if err := s.loginRL.Allow(email, s.ip(r)); err != nil {
|
|
return 0, nil, limited(err)
|
|
}
|
|
uid, err := s.Accounts.Authenticate(email, str(b, "password"), strings.TrimSpace(str(b, "totpCode")))
|
|
switch {
|
|
case errors.Is(err, ErrTOTPRequired):
|
|
return 0, nil, fail(401, "totp_required", "Enter the code from your authenticator app.")
|
|
case errors.Is(err, ErrTOTPInvalid):
|
|
return 0, nil, fail(401, "totp_invalid", "The authenticator code is not correct.")
|
|
case err != nil:
|
|
s.Log("install: login failed for %s", mailer.MaskEmail(email))
|
|
return 0, nil, fail(401, "invalid_credentials", "Email or password is incorrect.")
|
|
}
|
|
tn := str(b, "tokenName")
|
|
if tn == "" {
|
|
tn = "Verae installer"
|
|
}
|
|
var client map[string]string
|
|
if c, ok := b["client"].(map[string]any); ok {
|
|
client = map[string]string{}
|
|
for k, v := range c {
|
|
if sv, ok := v.(string); ok && len(sv) < 200 {
|
|
client[k] = sv
|
|
}
|
|
}
|
|
}
|
|
tok, err := s.Accounts.MintToken(uid, tn, client)
|
|
if err != nil {
|
|
return 0, nil, fail(500, "internal", "Please try again.")
|
|
}
|
|
s.Log("install: key issued for %s", mailer.MaskEmail(email))
|
|
return 201, map[string]any{"token": tok, "name": tn}, nil
|
|
}
|