PrintToVeraeTimeStamp/internal/installapi/installapi.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
}