authentication:send_sms
Differences
This shows you the differences between two versions of the page.
| Both sides previous revisionPrevious revisionNext revision | Previous revision | ||
| authentication:send_sms [2026/03/09 07:12] – [Step 3 : Built Node.js Server] sonali | authentication:send_sms [2026/03/09 07:27] (current) – [Upload server.js from local machine (run on Windows):] sonali | ||
|---|---|---|---|
| Line 161: | Line 161: | ||
| ==== ==== | ==== ==== | ||
| - | |||
| ===== Useful Commands ===== | ===== Useful Commands ===== | ||
| Line 188: | Line 187: | ||
| ===== Step 2 : Configured Keycloak Admin Console ===== | ===== Step 2 : Configured Keycloak Admin Console ===== | ||
| - | |||
| ==== 1 — Created Authentication Flow ==== | ==== 1 — Created Authentication Flow ==== | ||
| Line 231: | Line 229: | ||
| ===== Step 3 : Built Node.js Server ===== | ===== Step 3 : Built Node.js Server ===== | ||
| - | **File:** '' | + | **File: |
| '' | '' | ||
| < | < | ||
| + | |||
| 1. Receive: { kcUrl, realm, phone } | 1. Receive: { kcUrl, realm, phone } | ||
| 2. Get Keycloak admin token | 2. Get Keycloak admin token | ||
| Line 265: | Line 264: | ||
| </ | </ | ||
| + | |||
| ==== Key settings in server.js: ==== | ==== Key settings in server.js: ==== | ||
| - | < | + | < |
| + | app.set(' | ||
| app.use(cors({ origin: ' | app.use(cors({ origin: ' | ||
| Line 273: | Line 274: | ||
| sid = ' | sid = ' | ||
| token = ' | token = ' | ||
| - | from = 'DLT approved sender ID' '' | + | from = 'DLT approved sender ID' |
| </ | </ | ||
| + | |||
| **Steps on digital Ocean** | **Steps on digital Ocean** | ||
| **1. Install Node.js** | **1. Install Node.js** | ||
| + | |||
| < | < | ||
| curl -fsSL https:// | curl -fsSL https:// | ||
| Line 302: | Line 305: | ||
| </ | </ | ||
| - | |||
| ===== Step 4: Configured Nginx as Reverse Proxy ===== | ===== Step 4: Configured Nginx as Reverse Proxy ===== | ||
| DigitalOcean firewall blocked port 3000. Added Node.js routes to existing Nginx config at ''/ | DigitalOcean firewall blocked port 3000. Added Node.js routes to existing Nginx config at ''/ | ||
| + | |||
| < | < | ||
| location /api/ { | location /api/ { | ||
| Line 324: | Line 327: | ||
| </ | </ | ||
| + | sample server.js | ||
| - | ==== Upload | + | < |
| + | javascript | ||
| + | const express = require(' | ||
| + | |||
| + | const cors = require(' | ||
| + | |||
| + | const axios = require(' | ||
| + | |||
| + | const https = require(' | ||
| + | |||
| + | const qs = require(' | ||
| + | |||
| + | const path = require(' | ||
| + | |||
| + | const app = express(); | ||
| + | |||
| + | app.set(' | ||
| + | |||
| + | app.use(cors({ origin: ' | ||
| + | |||
| + | app.use(express.json()); | ||
| + | |||
| + | app.use(express.urlencoded({ extended: true })); | ||
| + | |||
| + | const httpsAgent = new https.Agent({ rejectUnauthorized: | ||
| + | |||
| + | const otpSessions = new Map(); | ||
| + | |||
| + | // Session cleanup (every 5 min, TTL 10 min) | ||
| + | |||
| + | setInterval(() => { | ||
| + | |||
| + | const cutoff = Date.now() - 10 * 60 * 1000; | ||
| + | |||
| + | for (const [id, session] of otpSessions) { | ||
| + | |||
| + | if (session.createdAt <cutoff) otpSessions.delete(id); | ||
| + | |||
| + | } | ||
| + | |||
| + | }, 5 * 60 * 1000); | ||
| + | |||
| + | // ── Helpers ─────────────────────────────────────────────────────────────────── | ||
| + | |||
| + | async function exchangeCodeForTokens(kcUrl, | ||
| + | |||
| + | const tokenUrl = `${kcUrl}/ | ||
| + | |||
| + | const res = await axios.post( | ||
| + | |||
| + | tokenUrl, | ||
| + | |||
| + | qs.stringify({ grant_type: ' | ||
| + | |||
| + | { headers: { ' | ||
| + | |||
| + | ); | ||
| + | |||
| + | return res.data; | ||
| + | |||
| + | } | ||
| + | |||
| + | function parseCookies(setCookieHeader) { | ||
| + | |||
| + | if (!setCookieHeader) return ''; | ||
| + | |||
| + | const arr = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader]; | ||
| + | |||
| + | return arr.map(c => c.split(';' | ||
| + | |||
| + | } | ||
| + | |||
| + | function mergeCookies(existing, | ||
| + | |||
| + | const parts = [...new Set([ | ||
| + | |||
| + | ...existing.split('; | ||
| + | |||
| + | ...incoming.split('; | ||
| + | |||
| + | ])]; | ||
| + | |||
| + | return parts.join('; | ||
| + | |||
| + | } | ||
| + | |||
| + | function generateSessionId() { | ||
| + | |||
| + | return Math.random().toString(36).slice(2) + Date.now().toString(36); | ||
| + | |||
| + | } | ||
| + | |||
| + | // ── SMS OTP Login ───────────────────────────────────────────────────────────── | ||
| + | |||
| + | app.post('/ | ||
| + | |||
| + | const { kcUrl, realm, phone } = req.body; | ||
| + | |||
| + | const smsClientId = ' | ||
| + | |||
| + | if (!kcUrl || !realm || !phone) | ||
| + | |||
| + | return res.status(400).json({ error: ' | ||
| + | |||
| + | // Step 1: Find username by phone via Keycloak Admin API | ||
| + | |||
| + | let username; | ||
| + | |||
| + | try { | ||
| + | |||
| + | const tokenRes = await axios.post( | ||
| + | |||
| + | | ||
| + | |||
| + | qs.stringify({ grant_type: ' | ||
| + | |||
| + | { headers: { ' | ||
| + | |||
| + | ); | ||
| + | |||
| + | const adminToken = tokenRes.data.access_token; | ||
| + | |||
| + | const normalizedPhone = phone.startsWith(' | ||
| + | |||
| + | const usersRes = await axios.get(`${kcUrl}/ | ||
| + | |||
| + | params: { q: `phone: | ||
| + | |||
| + | headers: { Authorization: | ||
| + | |||
| + | }); | ||
| + | |||
| + | const match = usersRes.data.find(u => { | ||
| + | |||
| + | const p = (u.attributes? | ||
| + | |||
| + | return p === normalizedPhone; | ||
| + | |||
| + | }); | ||
| + | |||
| + | if (!match) return res.status(404).json({ error: 'No user found with this phone number.' | ||
| + | |||
| + | username = match.username; | ||
| + | |||
| + | } catch (err) { | ||
| + | |||
| + | return res.status(500).json({ error: `Failed to look up user: ${err.response? | ||
| + | |||
| + | } | ||
| + | |||
| + | // Step 2: Trigger Keycloak SMS OTP flow | ||
| + | |||
| + | const redirectUri = `${req.protocol}:// | ||
| + | |||
| + | const authUrl = `${kcUrl}/ | ||
| + | |||
| + | try { | ||
| + | |||
| + | const authRes = await axios.get(authUrl, | ||
| + | |||
| + | params: { client_id: smsClientId, | ||
| + | |||
| + | httpsAgent, maxRedirects: | ||
| + | |||
| + | }); | ||
| + | |||
| + | if (authRes.status !== 200) | ||
| + | |||
| + | return res.status(400).json({ error: `Keycloak returned HTTP ${authRes.status} — is sms-login-client created?` }); | ||
| + | |||
| + | const actionMatch = authRes.data.match(/ | ||
| + | |||
| + | if (!actionMatch) return res.status(500).json({ error: 'Could not find login form.' }); | ||
| + | |||
| + | const loginActionUrl = actionMatch[1].replace(/&/ | ||
| + | |||
| + | const loginCookies = parseCookies(authRes.headers[' | ||
| + | |||
| + | const credRes = await axios.post(loginActionUrl, | ||
| + | |||
| + | headers: { ' | ||
| + | |||
| + | httpsAgent, maxRedirects: | ||
| + | |||
| + | }); | ||
| + | |||
| + | if (credRes.status === 302 && credRes.headers.location? | ||
| + | |||
| + | const code = new URL(credRes.headers.location).searchParams.get(' | ||
| + | |||
| + | const tokens = await exchangeCodeForTokens(kcUrl, | ||
| + | |||
| + | return res.json({ success: true, tokens }); | ||
| + | |||
| + | } | ||
| + | |||
| + | let otpHtml = typeof credRes.data === ' | ||
| + | |||
| + | let otpCookies = mergeCookies(loginCookies, | ||
| + | |||
| + | if (credRes.status === 302 && credRes.headers.location && !credRes.headers.location.includes(' | ||
| + | |||
| + | const otpPageRes = await axios.get(credRes.headers.location, | ||
| + | |||
| + | headers: { ' | ||
| + | |||
| + | }); | ||
| + | |||
| + | otpHtml = typeof otpPageRes.data === ' | ||
| + | |||
| + | otpCookies = mergeCookies(otpCookies, | ||
| + | |||
| + | } | ||
| + | |||
| + | const otpActionMatch = otpHtml? | ||
| + | |||
| + | if (!otpActionMatch) | ||
| + | |||
| + | return res.status(500).json({ error: 'SMS OTP form not found. Check sms-login-client flow and cotrav theme.' | ||
| + | |||
| + | const sessionId = generateSessionId(); | ||
| + | |||
| + | otpSessions.set(sessionId, | ||
| + | |||
| + | otpActionUrl: | ||
| + | |||
| + | cookies: otpCookies, kcUrl, realm, clientId: smsClientId, | ||
| + | |||
| + | }); | ||
| + | |||
| + | return res.json({ requiresOTP: | ||
| + | |||
| + | } catch (err) { | ||
| + | |||
| + | return res.status(500).json({ error: `SMS login failed: ${err.response? | ||
| + | |||
| + | } | ||
| + | |||
| + | }); | ||
| + | |||
| + | // ── SMS OTP Verify ──────────────────────────────────────────────────────────── | ||
| + | |||
| + | app.post('/ | ||
| + | |||
| + | const { sessionId, otp } = req.body; | ||
| + | |||
| + | if (!sessionId || !otp) return res.status(400).json({ error: ' | ||
| + | |||
| + | const session = otpSessions.get(sessionId); | ||
| + | |||
| + | if (!session) return res.status(400).json({ error: ' | ||
| + | |||
| + | try { | ||
| + | |||
| + | const otpRes = await axios.post(session.otpActionUrl, | ||
| + | |||
| + | headers: { ' | ||
| + | |||
| + | httpsAgent, maxRedirects: | ||
| + | |||
| + | }); | ||
| + | |||
| + | if (otpRes.status === 302 && otpRes.headers.location? | ||
| + | |||
| + | const code = new URL(otpRes.headers.location).searchParams.get(' | ||
| + | |||
| + | const { kcUrl, realm, clientId, redirectUri } = session; | ||
| + | |||
| + | const tokens = await exchangeCodeForTokens(kcUrl, | ||
| + | |||
| + | otpSessions.delete(sessionId); | ||
| + | |||
| + | return res.json({ success: true, tokens }); | ||
| + | |||
| + | } | ||
| + | |||
| + | if (otpRes.status === 200) return res.status(401).json({ error: ' | ||
| + | |||
| + | return res.status(400).json({ error: `Unexpected response: HTTP ${otpRes.status}` }); | ||
| + | |||
| + | } catch (err) { | ||
| + | |||
| + | return res.status(500).json({ error: `SMS OTP verification failed: ${err.message}` }); | ||
| + | |||
| + | } | ||
| + | |||
| + | }); | ||
| + | |||
| + | // ── Webhook — called by Keycloak SPI to send SMS via Exotel ────────────────── | ||
| + | |||
| + | app.post('/ | ||
| + | |||
| + | let { phone, otp, name } = req.body; | ||
| + | |||
| + | if (!phone || !otp) return res.status(400).json({ error: 'phone and otp required' | ||
| + | |||
| + | // Normalize phone to +91XXXXXXXXXX | ||
| + | |||
| + | if (!phone.startsWith(' | ||
| + | |||
| + | const sid = process.env.EXOTEL_SID | ||
| + | |||
| + | const token = process.env.EXOTEL_API_TOKEN | ||
| + | |||
| + | const from = process.env.EXOTEL_FROM | ||
| + | |||
| + | const message = `Dear ${name || ' | ||
| + | |||
| + | try { | ||
| + | |||
| + | const url = `https:// | ||
| + | |||
| + | const smsRes = await axios.post(url, | ||
| + | |||
| + | headers: { ' | ||
| + | |||
| + | validateStatus: | ||
| + | |||
| + | }); | ||
| + | |||
| + | console.log(' | ||
| + | |||
| + | return res.json({ success: true }); | ||
| + | |||
| + | } catch (err) { | ||
| + | |||
| + | return res.status(500).json({ error: `Exotel error: ${err.message}` }); | ||
| + | |||
| + | } | ||
| + | |||
| + | }); | ||
| + | |||
| + | // ── Start Server ────────────────────────────────────────────────────────────── | ||
| + | |||
| + | app.use(express.static(path.join(__dirname, | ||
| + | |||
| + | app.get('/', | ||
| + | |||
| + | app.listen(3000, | ||
| + | |||
| + | </ | ||
| + | ==== Upload server.js from local machine (run on Windows): ==== | ||
| < | < | ||
| + | |||
| scp d:/ | scp d:/ | ||
| </ | </ | ||
| + | |||
| ==== Start Node.js server: ==== | ==== Start Node.js server: ==== | ||
| Line 400: | Line 747: | ||
| < | < | ||
| - | * **Provider: | + | * **Provider: |
| - | * **Account SID:** '' | + | * **Account SID: |
| - | * **Sender ID:** '' | + | * **Sender ID: |
| - | * **API endpoint:** '' | + | * **API endpoint: |
| * **Approved message template:** | * **Approved message template:** | ||
| < | < | ||
| + | |||
| Dear {name}, | Dear {name}, | ||
| OTP for login to your Cotrav Hotel Agent App Is {otp} | OTP for login to your Cotrav Hotel Agent App Is {otp} | ||
| Line 412: | Line 760: | ||
| </ | </ | ||
| + | |||
| + | |||
authentication/send_sms.1773040342.txt.gz · Last modified: by sonali
