Created
February 10, 2026 08:23
-
-
Save tayyebi/49c88c975da96539fd438ea4448c271a to your computer and use it in GitHub Desktop.
A lite website to share your 2FA tokens with Google Apps Script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <base target="_top"> | |
| <link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet"> | |
| <style> | |
| html, | |
| body { | |
| height: 100%; | |
| margin: 0; | |
| font-family: 'Roboto', sans-serif; | |
| scroll-behavior: smooth; | |
| } | |
| section { | |
| height: 100vh; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| flex-direction: column; | |
| width: 100%; | |
| } | |
| #publicSection { | |
| background: #f5f5f5; | |
| } | |
| #privateSection { | |
| background: #e8f0fe; | |
| display: none; | |
| padding: 20px; | |
| box-sizing: border-box; | |
| } | |
| input { | |
| padding: 12px; | |
| font-size: 18px; | |
| width: 220px; | |
| text-align: center; | |
| border-radius: 6px; | |
| border: 1px solid #ccc; | |
| margin-bottom: 12px; | |
| } | |
| button { | |
| padding: 12px 24px; | |
| font-size: 16px; | |
| border-radius: 6px; | |
| border: none; | |
| background-color: #1976d2; | |
| color: white; | |
| cursor: pointer; | |
| margin: 6px; | |
| } | |
| button:hover { | |
| background-color: #1565c0; | |
| } | |
| .message { | |
| margin-top: 12px; | |
| font-size: 50px; | |
| color: green; | |
| } | |
| .error { | |
| color: red; | |
| } | |
| table { | |
| border-collapse: collapse; | |
| width: 90%; | |
| max-width: 900px; | |
| margin-top: 20px; | |
| background: white; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); | |
| } | |
| table th, | |
| table td { | |
| border: 1px solid #ccc; | |
| padding: 10px; | |
| text-align: center; | |
| } | |
| table th { | |
| background: #1976d2; | |
| color: white; | |
| font-weight: 500; | |
| } | |
| .modal { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.4); | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .modal-content { | |
| background: white; | |
| padding: 20px; | |
| border-radius: 8px; | |
| min-width: 320px; | |
| text-align: center; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- PUBLIC SECTION --> | |
| <section id="publicSection"> | |
| <h2>Enter Passphrase to Get 2FA</h2> | |
| <input id="tokenInput" placeholder="Passphrase"/> | |
| <button onclick="submitPassphrase()">Get 2FA</button> | |
| <div id="result" class="message"></div> | |
| <!-- Admin login link --> | |
| <p style="margin-top:20px;"> | |
| <a href="#" id="adminLoginLink" onclick="showAdminLogin(); return false;">Admin Login</a> | |
| </p> | |
| <!-- Hidden admin login form --> | |
| <div id="adminLoginForm" style="display:none; margin-top:10px;"> | |
| <input id="adminPasswordInput" type="password" placeholder="Admin Password"/> | |
| <button onclick="submitAdminLogin()">Login</button> | |
| </div> | |
| <div id="shortLink"> | |
| Use <a href="https://bit.ly/tayyebi-2fa">this link</a> to access this webpage. | |
| </div> | |
| </section> | |
| <section id="privateSection"> | |
| <h2>Admin Panel</h2> | |
| <div class="add-entity"> | |
| <input id="entityName" placeholder="Entity Name"/> | |
| <input id="entitySecret" placeholder="Base32 Secret"/> | |
| <button onclick="addEntity()">Add Entity</button> | |
| <button onclick="logoutAdmin()">Logout</button> | |
| </div> | |
| <table id="entitiesTable"> | |
| <thead> | |
| <tr> | |
| <th>Entity</th> | |
| <th>Passphrases (24h TTL)</th> | |
| <th>Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody></tbody> | |
| </table> | |
| </section> | |
| <div id="modal" class="modal"> | |
| <div class="modal-content"> | |
| <p id="modalText"></p> | |
| <button onclick="closeModal()">Close</button> | |
| </div> | |
| </div> | |
| <script> | |
| function setCookie(name, value, hours) { | |
| const d = new Date(); | |
| d.setTime(d.getTime() + (hours*60*60*1000)); | |
| document.cookie = `${name}=${value};expires=${d.toUTCString()};path=/`; | |
| } | |
| function getCookie(name) { | |
| const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); | |
| return match ? match[2] : null; | |
| } | |
| function deleteCookie(name) { | |
| document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/`; | |
| } | |
| // ---------- PUBLIC ---------- | |
| function submitPassphrase() { | |
| const passphrase = document.getElementById('tokenInput').value.trim(); | |
| if(!passphrase) return alert('Enter passphrase'); | |
| google.script.run.withSuccessHandler(code=>{ | |
| document.getElementById('result').textContent = code; | |
| }).get2FAByPassphrase(passphrase); | |
| } | |
| // ---------- ADMIN ---------- | |
| function addEntity() { | |
| const name = document.getElementById('entityName').value.trim(); | |
| const secret = document.getElementById('entitySecret').value.trim(); | |
| if(!name || !secret) { alert('Name and Secret required'); return; } | |
| google.script.run.withSuccessHandler(loadEntities) | |
| .withFailureHandler(e=>alert(e.message)) | |
| .createEntity(name, secret); | |
| document.getElementById('entityName').value = ''; | |
| document.getElementById('entitySecret').value = ''; | |
| } | |
| function loadEntities() { | |
| google.script.run.withSuccessHandler(entities=>{ | |
| const tbody = document.querySelector('#entitiesTable tbody'); | |
| tbody.innerHTML = ''; | |
| entities.forEach(e=>{ | |
| const tr = document.createElement('tr'); | |
| const passCell = document.createElement('td'); | |
| passCell.innerHTML = e.passphrases.map(p=>{ | |
| const age = Math.floor((Date.now() - p.createdAt)/1000/60); | |
| return `${p.value} (${age} min) <button onclick="revokePass('${e.id}','${p.value}')">Revoke</button>`; | |
| }).join('<br/>'); | |
| const actionCell = document.createElement('td'); | |
| actionCell.innerHTML = `<button onclick="createPass('${e.id}')">Create Passphrase</button>`; | |
| tr.innerHTML = `<td>${e.name}</td>`; | |
| tr.appendChild(passCell); | |
| tr.appendChild(actionCell); | |
| tbody.appendChild(tr); | |
| }); | |
| }).getEntities(); | |
| } | |
| function createPass(entityId) { google.script.run.withSuccessHandler(loadEntities).createPassphrase(entityId); } | |
| function revokePass(entityId, passphrase) { google.script.run.withSuccessHandler(loadEntities).revokePassphrase(entityId, passphrase); } | |
| function closeModal(){ document.getElementById('modal').style.display='none'; } | |
| function showAdminLogin() { | |
| document.getElementById('adminLoginForm').style.display = 'block'; | |
| document.getElementById('adminPasswordInput').focus(); | |
| } | |
| function submitAdminLogin() { | |
| const password = document.getElementById('adminPasswordInput').value.trim(); | |
| if(!password) return alert('Enter admin password'); | |
| google.script.run.withSuccessHandler(success=>{ | |
| if(success){ | |
| document.getElementById('privateSection').style.display='flex'; | |
| setCookie('adminLoggedIn','true',24); | |
| loadEntities(); | |
| document.getElementById('privateSection').scrollIntoView({behavior:'smooth'}); | |
| document.getElementById('adminLoginForm').style.display='none'; | |
| document.getElementById('adminPasswordInput').value=''; | |
| } else { | |
| alert('Wrong password'); | |
| } | |
| }).loginAdmin(password); | |
| } | |
| function login() { | |
| const password = prompt('Enter admin password:'); | |
| google.script.run.withSuccessHandler(success=>{ | |
| if(success){ | |
| document.getElementById('privateSection').style.display='flex'; | |
| setCookie('adminLoggedIn','true',24); | |
| loadEntities(); | |
| document.getElementById('privateSection').scrollIntoView({behavior:'smooth'}); | |
| } else alert('Wrong password'); | |
| }).loginAdmin(password); | |
| } | |
| function logoutAdmin(){ | |
| deleteCookie('adminLoggedIn'); | |
| document.getElementById('privateSection').style.display='none'; | |
| document.getElementById('publicSection').scrollIntoView({behavior:'smooth'}); | |
| } | |
| window.addEventListener('load', ()=>{ | |
| if(getCookie('adminLoggedIn')==='true'){ | |
| document.getElementById('privateSection').style.display='flex'; | |
| loadEntities(); | |
| document.getElementById('privateSection').scrollIntoView({behavior:'smooth'}); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const SCRIPT_PROPERTIES = PropertiesService.getScriptProperties(); | |
| const ADMIN_PASSWORD_KEY = 'Avh23FSO0fTy'; // Change to your admin password | |
| // ---------- WEB APP ENTRY ---------- | |
| function doGet(e) { | |
| return HtmlService.createHtmlOutputFromFile('Index') | |
| .setTitle('2FA Passphrase App') | |
| .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); | |
| } | |
| // ---------- ENTITY STORAGE ---------- | |
| function getEntities() { | |
| const json = SCRIPT_PROPERTIES.getProperty('entities'); | |
| return json ? JSON.parse(json) : []; | |
| } | |
| function saveEntities(entities) { | |
| SCRIPT_PROPERTIES.setProperty('entities', JSON.stringify(entities)); | |
| } | |
| // ---------- ENTITY MANAGEMENT ---------- | |
| function createEntity(name, secret) { | |
| if (!name) throw new Error('Entity name is required'); | |
| if (!secret) throw new Error('Secret is required'); | |
| if (!isValidSecret(secret)) throw new Error('Secret must be valid Base32 (A-Z,2-7)'); | |
| const entities = getEntities(); | |
| const id = Math.random().toString(36).substring(2, 8).toUpperCase(); | |
| entities.push({ | |
| id, | |
| name, | |
| secret, | |
| passphrases: [] | |
| }); | |
| saveEntities(entities); | |
| return entities; | |
| } | |
| // ---------- PASSPHRASE MANAGEMENT ---------- | |
| function createPassphrase(entityId) { | |
| const entities = getEntities(); | |
| const entity = entities.find(e => e.id === entityId); | |
| if (!entity) return null; | |
| const passphrase = Math.random().toString(36).substring(2, 8).toUpperCase(); | |
| const now = Date.now(); | |
| if(!entity.passphrases) entity.passphrases = []; | |
| entity.passphrases.push({ value: passphrase, createdAt: now }); | |
| saveEntities(entities); | |
| Logger.log(`Passphrase created for entity ${entity.name}: ${passphrase}`); | |
| return passphrase; | |
| } | |
| function revokePassphrase(entityId, passphraseValue) { | |
| const entities = getEntities(); | |
| const entity = entities.find(e => e.id === entityId); | |
| if (!entity || !entity.passphrases) return false; | |
| entity.passphrases = entity.passphrases.filter(p => p.value !== passphraseValue); | |
| saveEntities(entities); | |
| Logger.log(`Passphrase revoked for entity ${entity.name}: ${passphraseValue}`); | |
| return true; | |
| } | |
| // ---------- PUBLIC 2FA ---------- | |
| function get2FAByPassphrase(passphrase) { | |
| const entities = getEntities(); | |
| const now = Date.now(); | |
| for (const entity of entities) { | |
| if (!entity.passphrases) continue; | |
| for (const p of entity.passphrases) { | |
| if (p.value === passphrase && (now - p.createdAt) <= 24 * 60 * 60 * 1000) { | |
| Logger.log(`2FA requested for entity ${entity.name} using passphrase ${passphrase}`); | |
| return generateTOTP(entity.secret, now / 1000, 6, 30); | |
| } | |
| } | |
| } | |
| return 'Invalid or expired passphrase'; | |
| } | |
| // ---------- ADMIN LOGIN ---------- | |
| function loginAdmin(password) { | |
| return password === ADMIN_PASSWORD_KEY; | |
| } | |
| // ---------- TOTP IMPLEMENTATION ---------- | |
| function base32Decode(base32) { | |
| const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; | |
| const clean = base32.toUpperCase().replace(/=+$/, ''); | |
| let bits = ''; | |
| for (let i = 0; i < clean.length; i++) { | |
| const val = alphabet.indexOf(clean.charAt(i)); | |
| if (val < 0) throw new Error('Invalid base32 character'); | |
| bits += val.toString(2).padStart(5, '0'); | |
| } | |
| const bytes = []; | |
| for (let i = 0; i + 8 <= bits.length; i += 8) | |
| bytes.push(parseInt(bits.substring(i, i + 8), 2)); | |
| return bytes; | |
| } | |
| function sha1Bytes(messageBytes) { | |
| function rotl(n, b) { return ((n << b) | (n >>> (32 - b))) >>> 0; } | |
| const words = []; | |
| for (let i = 0; i < messageBytes.length * 8; i += 8) | |
| words[i >> 5] |= (messageBytes[i / 8] & 0xff) << (24 - i % 32); | |
| const bitLen = messageBytes.length * 8; | |
| words[bitLen >> 5] |= 0x80 << (24 - bitLen % 32); | |
| words[(((bitLen + 64) >> 9) << 4) + 15] = bitLen; | |
| let h0 = 0x67452301, h1 = 0xefcdab89, h2 = 0x98badcfe, h3 = 0x10325476, h4 = 0xc3d2e1f0; | |
| for (let i = 0; i < words.length; i += 16) { | |
| const w = new Array(80); | |
| for (let t = 0; t < 16; t++) w[t] = words[i + t] | 0; | |
| for (let t = 16; t < 80; t++) w[t] = rotl(w[t - 3] ^ w[t - 8] ^ w[t - 14] ^ w[t - 16], 1); | |
| let a = h0, b = h1, c = h2, d = h3, e = h4; | |
| for (let t = 0; t < 80; t++) { | |
| let f, k; | |
| if (t < 20) { f = (b & c) | ((~b) & d); k = 0x5a827999; } | |
| else if (t < 40) { f = b ^ c ^ d; k = 0x6ed9eba1; } | |
| else if (t < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8f1bbcdc; } | |
| else { f = b ^ c ^ d; k = 0xca62c1d6; } | |
| const temp = (rotl(a, 5) + f + e + k + w[t]) >>> 0; | |
| e = d; d = c; c = rotl(b, 30) >>> 0; b = a; a = temp; | |
| } | |
| h0 = (h0 + a) >>> 0; | |
| h1 = (h1 + b) >>> 0; | |
| h2 = (h2 + c) >>> 0; | |
| h3 = (h3 + d) >>> 0; | |
| h4 = (h4 + e) >>> 0; | |
| } | |
| const hash = [h0, h1, h2, h3, h4]; | |
| const out = []; | |
| for (let i = 0; i < hash.length; i++) { | |
| out.push((hash[i] >>> 24) & 0xff); | |
| out.push((hash[i] >>> 16) & 0xff); | |
| out.push((hash[i] >>> 8) & 0xff); | |
| out.push(hash[i] & 0xff); | |
| } | |
| return out; | |
| } | |
| function hmacSha1(keyBytes, messageBytes) { | |
| const blockSize = 64; | |
| if (keyBytes.length > blockSize) keyBytes = sha1Bytes(keyBytes); | |
| while (keyBytes.length < blockSize) keyBytes.push(0x00); | |
| const oKeyPad = keyBytes.map(b => b ^ 0x5c); | |
| const iKeyPad = keyBytes.map(b => b ^ 0x36); | |
| const inner = sha1Bytes(iKeyPad.concat(messageBytes)); | |
| return sha1Bytes(oKeyPad.concat(inner)); | |
| } | |
| function generateTOTP(secretBase32, time = Date.now() / 1000, digits = 6, period = 30) { | |
| const keyBytes = base32Decode(secretBase32); | |
| let counter = Math.floor(time / period); | |
| const counterBytes = new Array(8).fill(0); | |
| for (let i = 7; i >= 0; i--) { | |
| counterBytes[i] = counter & 0xff; | |
| counter >>>= 8; | |
| } | |
| const hmac = hmacSha1(keyBytes, counterBytes); | |
| const offset = hmac[hmac.length - 1] & 0xf; | |
| const binary = ((hmac[offset] & 0x7f) << 24) | | |
| ((hmac[offset + 1] & 0xff) << 16) | | |
| ((hmac[offset + 2] & 0xff) << 8) | | |
| (hmac[offset + 3] & 0xff); | |
| const otp = binary % Math.pow(10, digits); | |
| return otp.toString().padStart(digits, '0'); | |
| } | |
| function isValidSecret(secret) { | |
| return /^[A-Z2-7]+=*$/.test(secret); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment