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>
206 lines
8.2 KiB
JavaScript
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 :</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 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 :<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 }
|