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:00] – sonali | authentication:send_sms [2026/03/09 07:27] (current) – [Upload server.js from local machine (run on Windows):] sonali | ||
|---|---|---|---|
| Line 150: | Line 150: | ||
| < | < | ||
| - | '' | + | docker cp / |
| docker restart keycloak_app | docker restart keycloak_app | ||
| Line 160: | Line 160: | ||
| ==== ==== | ==== ==== | ||
| - | ==== Start Node.js server: | + | ==== |
| - | + | ||
| - | < | + | |
| - | '' | + | |
| - | nohup node server.js>> | + | |
| - | + | ||
| - | # Check it started | + | |
| - | tail -5 app.log | + | |
| - | + | ||
| - | </ | + | |
| - | < | + | |
| - | + | ||
| - | < | + | |
| - | cat> / | + | |
| - | server { | + | |
| - | listen 443 ssl; | + | |
| - | server_name 64.227.190.56; | + | |
| - | + | ||
| - | ssl_certificate | + | |
| - | ssl_certificate_key / | + | |
| - | + | ||
| - | add_header Strict-Transport-Security " | + | |
| - | add_header X-Frame-Options SAMEORIGIN; | + | |
| - | add_header X-Content-Type-Options nosniff; | + | |
| - | + | ||
| - | # Node.js API | + | |
| - | location /api/ { | + | |
| - | proxy_pass http:// | + | |
| - | proxy_set_header Host $host; | + | |
| - | proxy_set_header X-Real-IP $remote_addr; | + | |
| - | proxy_set_header X-Forwarded-Proto $scheme; | + | |
| - | } | + | |
| - | + | ||
| - | # Webhook | + | |
| - | location /webhook/ { | + | |
| - | proxy_pass http:// | + | |
| - | proxy_set_header Host $host; | + | |
| - | proxy_set_header X-Real-IP $remote_addr; | + | |
| - | proxy_set_header X-Forwarded-Proto $scheme; | + | |
| - | } | + | |
| - | + | ||
| - | # Keycloak\ | + | |
| - | location / { | + | |
| - | proxy_pass http:// | + | |
| - | proxy_set_header Host $host; | + | |
| - | proxy_set_header X-Real-IP $remote_addr; | + | |
| - | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | + | |
| - | proxy_set_header X-Forwarded-Proto $scheme; | + | |
| - | proxy_buffer_size 128k; | + | |
| - | proxy_buffers 4 256k; | + | |
| - | proxy_busy_buffers_size 256k; | + | |
| - | } | + | |
| - | } | + | |
| - | + | ||
| - | server { | + | |
| - | listen 80; | + | |
| - | server_name 64.227.190.56; | + | |
| - | return 301 https:// | + | |
| - | } | + | |
| - | EOF | + | |
| - | + | ||
| - | </ | + | |
| - | + | ||
| - | # Test and reload Nginx | + | |
| - | + | ||
| - | < | + | |
| - | nginx -t && nginx -s reload | + | |
| - | + | ||
| - | </ | + | |
| ===== Useful Commands ===== | ===== Useful Commands ===== | ||
| Line 254: | Line 186: | ||
| </ | </ | ||
| - | ===== Configured Keycloak Admin Console ===== | + | ===== Step 2 : Configured Keycloak Admin Console ===== |
| ==== 1 — Created Authentication Flow ==== | ==== 1 — Created Authentication Flow ==== | ||
| Line 294: | Line 226: | ||
| * Realm Settings → Themes → **Login Theme** | * Realm Settings → Themes → **Login Theme** | ||
| * Required so Keycloak finds '' | * Required so Keycloak finds '' | ||
| + | |||
| + | ===== Step 3 : Built Node.js Server ===== | ||
| + | |||
| + | **File: | ||
| + | |||
| + | '' | ||
| + | < | ||
| + | |||
| + | 1. Receive: { kcUrl, realm, phone } | ||
| + | 2. Get Keycloak admin token | ||
| + | 3. Search users by phone attribute → get username | ||
| + | 4. Fetch sms-login-client login form from Keycloak | ||
| + | 5. POST username to Keycloak → SPI runs → webhook called → SMS sent | ||
| + | 6. Save OTP form session → return { requiresOTP: | ||
| + | |||
| + | </ | ||
| + | |||
| + | === POST / | ||
| + | |||
| + | < | ||
| + | 1. Receive: { sessionId, otp } | ||
| + | 2. POST otp to Keycloak' | ||
| + | 3. Keycloak verifies OTP → returns auth code | ||
| + | 4. Exchange code for JWT tokens → return to client | ||
| + | |||
| + | </ | ||
| + | |||
| + | === POST / | ||
| + | |||
| + | < | ||
| + | 1. Receive: { phone, otp, name } | ||
| + | 2. Normalize phone: 8999463315 → +918999463315 | ||
| + | 3. Build message: | ||
| + | " | ||
| + | | ||
| + | 4. Call Exotel API → SMS delivered | ||
| + | |||
| + | </ | ||
| + | |||
| + | ==== Key settings in server.js: ==== | ||
| + | |||
| + | < | ||
| + | app.set(' | ||
| + | app.use(cors({ origin: ' | ||
| + | |||
| + | // Exotel config | ||
| + | sid = ' | ||
| + | token = ' | ||
| + | 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:// | ||
| dnf install -y nodejs | dnf install -y nodejs | ||
| Line 323: | Line 306: | ||
| </ | </ | ||
| - | ===== 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 ''/ | ||
| Line 343: | Line 326: | ||
| </ | </ | ||
| - | ==== Upload server.js from local machine (run on Windows): ==== | ||
| + | sample server.js | ||
| + | |||
| + | < | ||
| + | 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: ==== | ||
| + | |||
| + | < | ||
| + | cd / | ||
| + | nohup node server.js>> | ||
| + | # Check it started | ||
| + | tail -5 app.log | ||
| + | |||
| + | </ | ||
| + | |||
| + | **Step 7: Update Nginx Config** | ||
| + | < | ||
| + | cat> / | ||
| + | server { | ||
| + | listen 443 ssl; | ||
| + | server_name 64.227.190.56; | ||
| + | ssl_certificate | ||
| + | ssl_certificate_key / | ||
| + | |||
| + | add_header Strict-Transport-Security " | ||
| + | add_header X-Frame-Options SAMEORIGIN; | ||
| + | add_header X-Content-Type-Options nosniff; | ||
| + | |||
| + | # Node.js API | ||
| + | location /api/ { | ||
| + | | ||
| + | proxy_set_header Host $host; | ||
| + | proxy_set_header X-Real-IP $remote_addr; | ||
| + | proxy_set_header X-Forwarded-Proto $scheme; | ||
| + | } | ||
| + | |||
| + | # Webhook | ||
| + | location /webhook/ { | ||
| + | proxy_pass http:// | ||
| + | proxy_set_header Host $host; | ||
| + | proxy_set_header X-Real-IP $remote_addr; | ||
| + | proxy_set_header X-Forwarded-Proto $scheme; | ||
| + | } | ||
| + | |||
| + | # Keycloak\ | ||
| + | location / { | ||
| + | proxy_pass http:// | ||
| + | proxy_set_header Host $host; | ||
| + | proxy_set_header X-Real-IP $remote_addr; | ||
| + | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||
| + | proxy_set_header X-Forwarded-Proto $scheme; | ||
| + | proxy_buffer_size 128k; | ||
| + | proxy_buffers 4 256k; | ||
| + | proxy_busy_buffers_size 256k; | ||
| + | } | ||
| + | } | ||
| + | |||
| + | server { | ||
| + | listen 80; | ||
| + | server_name 64.227.190.56; | ||
| + | return 301 https:// | ||
| + | } | ||
| + | EOF | ||
| + | |||
| + | </ | ||
| + | |||
| + | **# Test and reload Nginx** | ||
| + | < | ||
| + | nginx -t && nginx -s reload | ||
| + | |||
| + | </ | ||
| + | < | ||
| + | |||
| + | * **Provider: | ||
| + | * **Account SID: | ||
| + | * **Sender ID: | ||
| + | * **API endpoint: | ||
| + | * **Approved message template:** | ||
| + | < | ||
| + | |||
| + | Dear {name}, | ||
| + | OTP for login to your Cotrav Hotel Agent App Is {otp} | ||
| + | Regards, | ||
| + | Cotrav | ||
| + | |||
| + | </ | ||
| + | |||
| + | |||
authentication/send_sms.1773039603.txt.gz · Last modified: by sonali
