gigafibre-fsm/services/targo-hub/lib/portal-auth.js
louispaulb 2b04e6bd86 feat(portal): passwordless magic-link login — retire ERPNext /login
Customers no longer authenticate with passwords. A POST to the hub's
/portal/request-link mints a 24h customer-scoped JWT and sends it via
email + SMS; the /#/login Vue page sits on top of this and a navigation
guard hydrates the Pinia store from the token on arrival.

Why now: legacy customer passwords are unsalted MD5 from the old PHP
system. Migrating hashes to PBKDF2 would still require a forced reset
for every customer, so it's simpler to drop passwords entirely. The
earlier Authentik forwardAuth attempt was already disabled on
client.gigafibre.ca; this removes the last vestige of ERPNext's
password form from the customer-facing path.

Hub changes:
  - services/targo-hub/lib/portal-auth.js (new) — POST /portal/request-link
    • 3-requests / 15-min per identifier rate limit (in-memory Map + timer)
    • Lookup by email (email_id + email_billing), customer id (legacy +
      direct name), or phone (cell + tel_home)
    • Anti-enumeration: always 200 OK with redacted contact hint
    • Email template with CTA button + raw URL fallback; SMS short form
  - services/targo-hub/server.js — mount the new /portal/* router

Client changes:
  - apps/client/src/pages/LoginPage.vue (new) — standalone full-page,
    single identifier input, success chips, rate-limit banner
  - apps/client/src/api/auth-portal.js (new) — thin fetch wrapper
  - apps/client/src/stores/customer.js — hydrateFromToken() sync decoder,
    stripTokenFromUrl (history.replaceState), init() silent Authentik
    fallback preserved for staff impersonation
  - apps/client/src/router/index.js — PUBLIC_ROUTES allowlist + guard
    that hydrates from URL token before redirecting
  - apps/client/src/api/auth.js — logout() clears store + bounces to
    /#/login (no more Authentik redirect); 401 in authFetch is warn-only
  - apps/client/src/composables/useMagicToken.js — thin read-through to
    the store (no more independent decoding)
  - PaymentSuccess/Cancel/CardAdded pages — goToLogin() uses router,
    not window.location to id.gigafibre.ca

Infra:
  - apps/portal/traefik-client-portal.yml — block /login and
    /update-password on client.gigafibre.ca, redirect to /#/login.
    Any stale bookmark or external link lands on the Vue page, not
    ERPNext's password form.

Docs:
  - docs/roadmap.md — Phase 4 checkbox flipped; MD5 migration item retired
  - docs/features/billing-payments.md — replace MD5 reset note with
    magic-link explainer

Online appointment booking (Plan B from the same discussion) is queued
for a follow-up session; this commit is Plan A only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 13:25:28 -04:00

206 lines
8.2 KiB
JavaScript

'use strict'
/**
* Portal passwordless authentication.
*
* Exposes POST /portal/request-link — the customer types their email or phone,
* we look them up in ERPNext, mint a 24 h customer JWT, and send the portal
* URL via SMS + email. The portal's useMagicToken() composable reads the
* token from the URL and hydrates the customer store.
*
* identifier ─► Customer (email_id | email_billing | cell_phone | tel_home
* | legacy_customer_id | Customer.name)
* │
* ├─► SMS: "Gigafibre — Accédez à votre portail: {link}"
* └─► Email: HTML template with CTA button + full URL
*
* Security:
* - **Anti-enumeration**: we always return 200 OK, whether the identifier
* matched a customer or not. Prevents "is this email in your system?"
* probes. The internal log still records the miss.
* - **Rate limit**: 3 requests per 15 min per identifier (case-insensitive).
* Uses an in-memory Map with a cleanup timer — survives restart loss
* gracefully (worst case, a throttled user gets to try again after a
* restart, which is fine).
* - **Token TTL**: 24 h. After that, the user requests a new link.
* - **JWT signature**: HS256 with cfg.JWT_SECRET. The portal never has the
* secret, so it trusts the server's signature implicitly (the token
* arrives over HTTPS from the user's own email/SMS).
*
* This endpoint replaces ERPNext's /login for portal customers. Staff still
* authenticate via Authentik forwardAuth on ops.gigafibre.ca and erp.*;
* customers never touch Authentik.
*/
const { log, json, parseBody, erpFetch, lookupCustomerByPhone } = require('./helpers')
const { generateCustomerToken } = require('./magic-link')
const cfg = require('./config')
const PORTAL_URL = cfg.CLIENT_PORTAL_URL || 'https://portal.gigafibre.ca'
const RATE_LIMIT_MAX = 3
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000
const LINK_TTL_HOURS = 24
// identifier (lower-cased) → { count, windowStart }
const rateLimits = new Map()
function checkRateLimit (key) {
const now = Date.now()
const entry = rateLimits.get(key)
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
rateLimits.set(key, { count: 1, windowStart: now })
return { ok: true }
}
if (entry.count >= RATE_LIMIT_MAX) {
return { ok: false, retryAfterMs: RATE_LIMIT_WINDOW_MS - (now - entry.windowStart) }
}
entry.count++
return { ok: true }
}
// Cull expired windows every 15 min so the Map doesn't grow unbounded.
setInterval(() => {
const now = Date.now()
for (const [k, v] of rateLimits) {
if (now - v.windowStart > RATE_LIMIT_WINDOW_MS) rateLimits.delete(k)
}
}, RATE_LIMIT_WINDOW_MS).unref()
function erpQuery (doctype, filters, fields, limit = 1) {
const url = `/api/resource/${encodeURIComponent(doctype)}?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=${encodeURIComponent(JSON.stringify(fields))}&limit_page_length=${limit}`
return erpFetch(url)
}
const CUSTOMER_FIELDS = ['name', 'customer_name', 'cell_phone', 'tel_home', 'email_id', 'email_billing']
async function lookupCustomer (raw) {
const cleaned = String(raw).trim()
if (!cleaned) return null
// Email — try both email_id and email_billing
if (cleaned.includes('@')) {
const email = cleaned.toLowerCase()
for (const field of ['email_id', 'email_billing']) {
const r = await erpQuery('Customer', [[field, '=', email]], CUSTOMER_FIELDS)
if (r.status === 200 && r.data?.data?.length) return r.data.data[0]
}
return null
}
// Explicit customer ID (ERPNext name or legacy id)
if (/^(C-|CUST-|GI-)/i.test(cleaned) || /^\d{4,7}$/.test(cleaned)) {
// Try legacy_customer_id
const legacy = await erpQuery('Customer', [['legacy_customer_id', '=', cleaned]], CUSTOMER_FIELDS)
if (legacy.status === 200 && legacy.data?.data?.length) return legacy.data.data[0]
// Try direct Customer.name
try {
const r = await erpFetch(`/api/resource/Customer/${encodeURIComponent(cleaned)}?fields=${encodeURIComponent(JSON.stringify(CUSTOMER_FIELDS))}`)
if (r.status === 200 && r.data?.data) return r.data.data
} catch (e) { /* fall through */ }
return null
}
// Phone — lookupCustomerByPhone returns minimal fields; re-fetch for email
const match = await lookupCustomerByPhone(cleaned)
if (!match) return null
try {
const r = await erpFetch(`/api/resource/Customer/${encodeURIComponent(match.name)}?fields=${encodeURIComponent(JSON.stringify(CUSTOMER_FIELDS))}`)
if (r.status === 200 && r.data?.data) return r.data.data
} catch (e) { /* use minimal match */ }
return match
}
function redactEmail (email) {
if (!email) return ''
const [user, domain] = email.split('@')
if (!domain) return '***'
const u = user.length <= 2
? user[0] + '*'
: user[0] + '*'.repeat(Math.max(1, user.length - 2)) + user.slice(-1)
return `${u}@${domain}`
}
function redactPhone (phone) {
if (!phone) return ''
const digits = phone.replace(/\D/g, '')
if (digits.length < 4) return '***'
return `***-***-${digits.slice(-4)}`
}
function loginEmailHtml (customerName, link) {
return `
<div style="font-family:sans-serif;max-width:500px;margin:0 auto;padding:20px">
<img src="https://store.targo.ca/clients/images/logo_trans_noir.png" width="160" alt="Gigafibre" /><br><br>
<p>Bonjour ${customerName || ''},</p>
<p>Voici votre lien d'accès au portail client Gigafibre&nbsp;:</p>
<p style="text-align:center;margin:24px 0">
<a href="${link}" style="display:inline-block;padding:12px 32px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;font-weight:bold">
Accéder à mon portail
</a>
</p>
<p style="color:#888;font-size:13px">Ce lien est valide pour 24&nbsp;heures. Si vous n'avez pas demandé cet accès, vous pouvez ignorer ce courriel.</p>
<p style="color:#888;font-size:12px;word-break:break-all">Lien complet&nbsp;:<br><code>${link}</code></p>
</div>`
}
async function handle (req, res, method, path) {
if (path === '/portal/request-link' && method === 'POST') {
const body = await parseBody(req)
const rawId = body && typeof body.identifier === 'string' ? body.identifier : ''
if (!rawId.trim()) return json(res, 400, { error: 'identifier_required' })
const rateKey = rawId.trim().toLowerCase()
const rl = checkRateLimit(rateKey)
if (!rl.ok) {
const retryMin = Math.ceil(rl.retryAfterMs / 60000)
return json(res, 429, {
error: 'rate_limit',
message: `Trop de tentatives. Réessayez dans ${retryMin} min.`,
retry_after_sec: Math.ceil(rl.retryAfterMs / 1000),
})
}
const customer = await lookupCustomer(rawId)
if (!customer) {
// Anti-enumeration: pretend success. Log internally for ops visibility.
log(`Portal request-link: no match for "${rawId}"`)
return json(res, 200, { ok: true, sent: [] })
}
const email = customer.email_id || customer.email_billing || ''
const token = generateCustomerToken(customer.name, customer.customer_name, email, LINK_TTL_HOURS)
const link = `${PORTAL_URL}/#/?token=${token}`
const sent = []
const redacted = []
const phone = customer.cell_phone || customer.tel_home
if (phone) {
try {
const { sendSmsInternal } = require('./twilio')
await sendSmsInternal(phone, `Gigafibre — Accédez à votre portail:\n${link}\n(Valide 24h)`)
sent.push('sms')
redacted.push(redactPhone(phone))
} catch (e) { log('Portal link SMS error:', e.message) }
}
if (email) {
try {
const { sendEmail } = require('./email')
await sendEmail({
to: email,
subject: "Gigafibre — Votre lien d'accès au portail",
html: loginEmailHtml(customer.customer_name, link),
})
sent.push('email')
redacted.push(redactEmail(email))
} catch (e) { log('Portal link email error:', e.message) }
}
log(`Portal link for ${customer.name}: sent via ${sent.join(',') || 'NONE'}`)
return json(res, 200, { ok: true, sent, redacted })
}
return json(res, 404, { error: 'portal_endpoint_not_found' })
}
module.exports = { handle, lookupCustomer, redactEmail, redactPhone }