Browse Source

Login from email

isundil 1 month ago
parent
commit
b44cbac2b8
8 changed files with 310 additions and 121 deletions
  1. 1 0
      package.json
  2. 27 0
      router/api.js
  3. 39 25
      src/config.js
  4. 52 4
      src/security.js
  5. 28 0
      static/public/js/access.js
  6. 118 89
      static/public/js/uiAccess.js
  7. 32 2
      static/public/js/uiShare.js
  8. 13 1
      templates/_login.js

+ 1 - 0
package.json

@@ -29,6 +29,7 @@
     "leaflet": "^1.9.4",
     "mime-types": "^2.1.35",
     "node-simple-router": "^0.10.2",
+    "nodemailer": "^7.0.10",
     "offline-geocoder": "^1.0.0",
     "sharp": "^0.33.3",
     "sqlite3": "^5.1.6",

+ 27 - 0
router/api.js

@@ -7,6 +7,7 @@ const MediaService = require('../model/mediaService.js');
 const MediaFileMetaModel = require('../model/mediaItemMeta.js').MediaFileMetaModel;
 const MediaFileTagModel = require('../model/mediaItemTag.js').MediaFileTagModel;
 const { AccessModel, ACCESS_TYPE, ACCESS_GRANT, ACCESS_TO } = require('../model/access.js');
+const MailService = require('../src/mailService.js');
 
 function MediaToJson(mediaData) {
     if (!mediaData)
@@ -123,6 +124,32 @@ module.exports = { register: app => {
         const result = Security.setAdmin(req, !!(access?.length || 0));
         app.routerUtils.jsonResponse(res, result);
     });
+    app.router.post("/api/access/totp/send", async(req, res) => {
+        app.routerUtils.onApiRequest(req, res);
+        if (!req.post?.email)
+            return app.routerUtils.httpResponse(res, 400, "Missing argument");
+        const access = await app.databaseHelper.findOne(AccessModel, { type: ACCESS_TYPE.email, typeData: req.post.email.trim() });
+        if (access) {
+            const code = Security.GenerateTotp(access.typeData);
+            const codeHumanReadable = Array.from(code).map((x, idx) => x + (idx %3 === 2? ' ' : '')).join('').trim();
+            MailService.sendMail(access.typeData, "Photochamber - Your code", require("../templates/authEmailTotp.js")({code: codeHumanReadable}));
+        }
+        app.routerUtils.jsonResponse(res, {});
+    });
+    app.router.post("/api/access/totp/validate", async(req, res) => {
+        app.routerUtils.onApiRequest(req, res);
+        if (!req.post?.email || !req.post?.code)
+            return app.routerUtils.httpResponse(res, 400, "Missing argument");
+        const access = await app.databaseHelper.fetch(AccessModel, { type: ACCESS_TYPE.email, typeData: req.post.email.trim() });
+        if (access.length && Security.ValidateTotp(access[0].typeData, req.post.code.trim())) {
+            Security.EmailLoginToSession(req, access);
+            if (access.find(x => x.accessTo == ACCESS_TO.admin))
+                Security.setAdmin(req, true);
+            app.routerUtils.jsonResponse(res, await accessListToJson(app, req));
+        } else {
+            app.routerUtils.onBadRequest(res, "Invalid Email or Code");
+        }
+    });
     app.router.post("/api/accessAdmin/create", async (req, res) => {
         app.routerUtils.onApiRequest(req, res);
         if (!await req.sessionObj?.accessList?.isAdmin(app, req.sessionObj?.accessList) || !req.body)

+ 39 - 25
src/config.js

@@ -1,34 +1,48 @@
-
-const CONFIG = require('craftlabhttpserver/src/config.js');
+const CONFIG = require("craftlabhttpserver/src/config.js");
 
 const FILENAME = process.mainModule.path + "/config.json";
-const fs = require('fs');
-const path = require('path');
+const fs = require("fs");
+const path = require("path");
 
 let configEntries = CONFIG;
 
+function redact(config) {
+  let result = {};
+  for (var i in config)
+    if (typeof config[i] !== 'function')
+        result[i] = config[i];
+  result.mailConfig.mailFromPasswd = "*****";
+  return result;
+}
+
 (() => {
-    CONFIG.Initialize({
-            port: { value: 80, valid: CONFIG.validNumber },
-            instanceHostname: { value: require('os').hostname(), valid: CONFIG.validNotEmptyString },
-            ldapUrl: { value: "", valid: CONFIG.validNotEmptyString },
-            ldapBindAttribute: { value: "", valid: CONFIG.validNotEmptyString },
-            ldapBindBase: { value: "", valid: CONFIG.validNotEmptyString },
-            database: { value: "", valid: CONFIG.validNotEmptyString }
-        });
-
-    configEntries.photoLibraries = [];
-    let hasErrors = false;
-    for (let i of CONFIG.getInitialContent().photoLibraries)
-        if (CONFIG.validNotEmptyString(i))
-            configEntries.photoLibraries.push(i);
-
-    configEntries.ldapFilters = [];
-
-    console.log(configEntries);
-    if (hasErrors)
-        throw "Errors found while parsing configuration";
+  CONFIG.Initialize({
+    port: { value: 80, valid: CONFIG.validNumber },
+    instanceHostname: {
+      value: require("os").hostname(),
+      valid: CONFIG.validNotEmptyString,
+    },
+    ldapUrl: { value: "", valid: CONFIG.validNotEmptyString },
+    ldapBindAttribute: { value: "", valid: CONFIG.validNotEmptyString },
+    ldapBindBase: { value: "", valid: CONFIG.validNotEmptyString },
+    database: { value: "", valid: CONFIG.validNotEmptyString },
+  });
+
+  configEntries.photoLibraries = [];
+  let hasErrors = false;
+  for (let i of CONFIG.getInitialContent().photoLibraries)
+    if (CONFIG.validNotEmptyString(i)) configEntries.photoLibraries.push(i);
+
+  configEntries.mailConfig = {
+    host: "mail.craftlab.cc",
+    port: 465,
+    secure: true,
+    mailFrom: "photochamber@knacki.info",
+    mailFromPasswd: "KaGw4JWc",
+  };
+
+  console.log(redact(configEntries));
+  if (hasErrors) throw "Errors found while parsing configuration";
 })();
 
 module.exports = configEntries;
-

+ 52 - 4
src/security.js

@@ -7,6 +7,8 @@ const CONFIG = require('./config.js');
 
 module.exports = require('craftlabhttpserver/src/security.js');
 
+const instanceSecret = crypto.randomUUID().replaceAll("-", "");
+
 function getAccessList(cookieObject) {
     let session = module.exports.getSessionObj(cookieObject);
     if (!session)
@@ -35,6 +37,17 @@ function LdapAccess(dbId, username) {
 LdapAccess.prototype = Object.create(Access.prototype);
 LdapAccess.prototype.id = function() { return `LDAP_${this.ldapDn}_${this.dbId}`; }
 
+function EmailAccess(dbId, email) {
+    Access.call(this);
+    this.dbId = dbId;
+    this.email = email;
+}
+EmailAccess.prototype = Object.create(Access.prototype);
+EmailAccess.prototype.id = function() {
+    const emailEscaped = this.email.replaceAll(/[^0-9A-Za-z]/g, "");
+    return `EMAIL_${emailEscaped}_${this.dbId}`;
+}
+
 module.exports.getAccessList = getAccessList;
 module.exports.createSession = req => {
     const now = Date.now();
@@ -77,6 +90,15 @@ module.exports.addLinkToSession = (req, dbId, linkId, linkLabel) => {
     session.accessList[accessItem.id()] = accessItem;
     return session.accessList;
 };
+module.exports.EmailLoginToSession = (req, accessModels) => {
+    let session = module.exports.getSessionObj(req.cookies);
+    if (!session)
+        return;
+    for (let i of accessModels) {
+        let accessItem = new EmailAccess(i.id, i.typeData);
+        session.accessList[accessItem.id()] = accessItem;
+    }
+}
 module.exports.tryLoginIntoSession = (req, accessModels, username, password) => {
     let session = module.exports.getSessionObj(req.cookies);
     if (!session)
@@ -95,9 +117,11 @@ module.exports.tryLoginIntoSession = (req, accessModels, username, password) =>
                     console.error(`${CONFIG.ldapBindAttribute}=${username},${CONFIG.ldapBindBase}: `, err.lde_message);
                     ko(err.lde_message);
                 }
-                else for (let i of accessModels) {
-                    let accessItem = new LdapAccess(i.id, username);
-                    session.accessList[accessItem.id()] = accessItem;
+                else {
+                    for (let i of accessModels) {
+                        let accessItem = new LdapAccess(i.id, username);
+                        session.accessList[accessItem.id()] = accessItem;
+                    }
                     ok?.(session.accessList);
                 }
             });
@@ -111,7 +135,31 @@ module.exports.removeFromSession = (req, accessId) => {
     delete session.accessList[accessId];
     return session.accessList;
 };
-
+function GenerateTotp(period, email) {
+    const secret = crypto.createHash("SHA-1").update(`${instanceSecret}${email}`).digest("base64");
+    const hash = crypto.createHmac("SHA-1", secret)
+        .update(period.toString())
+        .digest();
+    let offset = hash[hash.length - 1] & 0xF;
+    var truncatedHash = ((hash[offset + 0] & 0x7F) << 24 |
+        (hash[offset + 1] & 0xFF) << 16 |
+        (hash[offset + 2] & 0xFF) << 8 | 
+        (hash[offset + 3] & 0xFF)) % (10 ** 6);
+    return (`${truncatedHash}`.padStart(6, '0'));
+}
+module.exports.GenerateTotp = email => {
+    const period = Math.floor(Date.now() / (1000 * 30 * 60));
+    return GenerateTotp(period, email);
+}
+module.exports.ValidateTotp = (email, code) => {
+    code = code.replaceAll(/[^0-9]/g, "");
+    if (code.length !== 6)
+        return false;
+    const period = Math.floor(Date.now() / (1000 * 30 * 60));
+    const codes = [ GenerateTotp(period, email), GenerateTotp(period -1, email) ];
+    const codeBuffer = Buffer.from(code);
+    return !!codes.find(x => crypto.timingSafeEqual(Buffer.from(x), codeBuffer));
+}
 module.exports.setAdminFlat = () => {
     session.accessList.isAdmin_ = true;
 }

+ 28 - 0
static/public/js/access.js

@@ -72,6 +72,34 @@ $(() => {
             });
         }
 
+        SendTotpCodeToEmail(email) {
+            return new Promise(ok => {
+                $.ajax({
+                    url: "/api/access/totp/send",
+                    type: "POST",
+                    data: { email: email },
+                    success: () => ok(),
+                    error: () => ok()
+                });
+            });
+        }
+
+        LoginUserEmailAndTotp(email, code) {
+            return new Promise((ok, ko) => {
+                $.ajax({
+                    url: "/api/access/totp/validate",
+                    type: "POST",
+                    data: { email: email, code: code },
+                    success: async data => {
+                        this.#isAdmin = data.isAdmin;
+                        window.ReloadAccessList(data);
+                        ok();
+                    },
+                    error: err => ko(err.responseText)
+                });
+            });
+        }
+
         LoginUserPass(username, password) {
             return new Promise((ok, ko) => {
                 $.ajax({

+ 118 - 89
static/public/js/uiAccess.js

@@ -1,132 +1,161 @@
-
 $(() => {
-
-document.getElementById("menu-login").addEventListener("click", e => {
+  document.getElementById("menu-login").addEventListener("click", (e) => {
     e.preventDefault();
-    document.body.classList.add("login-visible")
+    document.body.classList.add("login-visible");
     document.body.classList.add("overlay-visible");
     document.Title.pushTitle("Login");
-});
+  });
 
-function closeLoginPopin() {
-    if (!document.body.classList.contains("login-visible"))
-        return;
+  function closeLoginPopin() {
+    if (!document.body.classList.contains("login-visible")) return;
     document.Title.pop();
     document.body.classList.remove("login-visible");
     document.body.classList.remove("overlay-visible");
     let inputFields = document.querySelectorAll(".login-wrapper .input-group input");
-    for (let i =0; i < inputFields.length; ++i) {
-        if (inputFields[i].type !== 'submit')
-            inputFields[i].value = "";
-    }
-    document.querySelector("#login-userpass + .error").classList.add("hidden");
-}
+    for (let i = 0; i < inputFields.length; ++i)
+      if (inputFields[i].type !== "submit") inputFields[i].value = "";
+    $(".login-wrapper .error").addClass("hidden");
+    document.querySelector("#login-email input[name='code']").classList.add("hidden");
+  }
 
-document.onClosePopinRequested(() => {
+  document.onClosePopinRequested(() => {
     closeLoginPopin();
-});
-
-document.querySelector(".login-wrapper .modal-footer button").addEventListener("click", () => closeLoginPopin());
+  });
 
-let loginUserPass = document.getElementById("login-userpass");
-let loginCode = document.getElementById("login-code");
+  document
+    .querySelector(".login-wrapper .modal-footer button")
+    .addEventListener("click", () => closeLoginPopin());
 
-loginUserPass.parentElement.addEventListener("submit", e => {
-    e.preventDefault();
-    let user = loginUserPass.querySelector("input[type='text']").value;
-    let pass = loginUserPass.querySelector("input[type='password']").value;
-    LoadingTasks.push(async () => {
+  (() => {
+    let loginUserPass = document.getElementById("login-userpass");
+    loginUserPass.parentElement.addEventListener("submit", (e) => {
+      e.preventDefault();
+      let user = loginUserPass.querySelector("input[type='text']").value;
+      let pass = loginUserPass.querySelector("input[type='password']").value;
+      LoadingTasks.push(async () => {
         try {
-            await AccessManager.LoginUserPass(user, pass);
+          await AccessManager.LoginUserPass(user, pass);
+        } catch (err) {
+          let errDiv = loginUserPass.parentElement.querySelector(".error");
+          errDiv.textContent = err;
+          errDiv.classList.remove("hidden");
+          return;
         }
-        catch (err) {
-            let errDiv = loginUserPass.parentElement.querySelector(".error");
+        await MediaStorage.Instance.rebuildMetaList();
+        closeLoginPopin();
+      });
+    });
+  })();
+
+  (() => {
+    let loginMailTotp = document.getElementById("login-email");
+    loginMailTotp.parentElement.addEventListener("submit", (e) => {
+      e.preventDefault();
+      let email = loginMailTotp.querySelector("input[type='email']").value;
+      let code = loginMailTotp.querySelector("input[type='text']");
+      if (code.classList.contains("hidden")) {
+        LoadingTasks.push(async () => {
+          await AccessManager.SendTotpCodeToEmail(email);
+          code.classList.remove("hidden");
+        });
+      } else {
+        LoadingTasks.push(async () => {
+          try {
+            await AccessManager.LoginUserEmailAndTotp(email, code.value);
+          } catch (err) {
+            let errDiv = loginMailTotp.parentElement.parentElement.querySelector(".error");
             errDiv.textContent = err;
             errDiv.classList.remove("hidden");
             return;
-        }
-        await MediaStorage.Instance.rebuildMetaList();
-        closeLoginPopin();
+          }
+          await MediaStorage.Instance.rebuildMetaList();
+          closeLoginPopin();
+        });
+      }
     });
-});
+  })();
 
-loginCode.querySelector("button").addEventListener("click", () => {
-    let code = loginCode.querySelector("input").value;
-    if (!code)
-        return;
-    LoadingTasks.push(async () => {
+  (() => {
+    let loginCode = document.getElementById("login-code");
+    loginCode.querySelector("button").addEventListener("click", () => {
+      let code = loginCode.querySelector("input").value;
+      if (!code) return;
+      LoadingTasks.push(async () => {
         await AccessManager.LinkLogin(new Set([code]));
         await MediaStorage.Instance.rebuildMetaList();
         closeLoginPopin();
+      });
     });
-});
+  })();
 
-async function logout(accessId) {
+  async function logout(accessId) {
     await AccessManager.Logout(accessId);
     await MediaStorage.Instance.rebuildMetaList();
     closeLoginPopin();
-}
+  }
 
-window.ReloadAccessList = function(accessList) {
+  window.ReloadAccessList = function (accessList) {
     if (accessList.isAdmin) {
-        document.getElementById("menu-dbAdmin-container").classList.remove("hidden");
-        document.getElementById("pch-navbar-share").classList.remove("hidden");
-        document.getElementById("pch-navbar-autotags").classList.remove("hidden");
+      document
+        .getElementById("menu-dbAdmin-container")
+        .classList.remove("hidden");
+      document.getElementById("pch-navbar-share").classList.remove("hidden");
+      document.getElementById("pch-navbar-autotags").classList.remove("hidden");
     } else {
-        document.getElementById("menu-dbAdmin-container").classList.add("hidden");
-        document.getElementById("pch-navbar-share").classList.add("hidden");
-        document.getElementById("pch-navbar-autotags").classList.add("hidden");
+      document.getElementById("menu-dbAdmin-container").classList.add("hidden");
+      document.getElementById("pch-navbar-share").classList.add("hidden");
+      document.getElementById("pch-navbar-autotags").classList.add("hidden");
     }
 
-    let getIconForType = type => {
-        if (!!type.ldapDn) return "bi-database";
-        if (!!type.linkId) return "bi-link-45deg";
-        if (!!type.userName) return "bi-person";
-        return "bi-question-octagon";
-    }
+    let getIconForType = (type) => {
+      if (!!type.ldapDn) return "bi-database";
+      if (!!type.linkId) return "bi-link-45deg";
+      if (!!type.email) return "bi-envelope-at";
+      if (!!type.userName) return "bi-person";
+      return "bi-question-octagon";
+    };
 
     let rootNode = document.getElementById("accessListMenu");
     let items = rootNode.querySelectorAll("li.accessItem");
-    for (let i =0; i < items.length; ++i)
-        items[i].remove();
+    for (let i = 0; i < items.length; ++i) items[i].remove();
     delete accessList.isAdmin;
     delete accessList.isAdmin_;
-    if (Object.keys(accessList||{}).length) {
-        let li = document.createElement("li");
-        li.classList.add("accessItem");
-        li.classList.add("divider");
-        let hr = document.createElement("hr");
-        hr.classList.add("dropdown-divider");
-        li.appendChild(hr);
-        rootNode.appendChild(li);
+    if (Object.keys(accessList || {}).length) {
+      let li = document.createElement("li");
+      li.classList.add("accessItem");
+      li.classList.add("divider");
+      let hr = document.createElement("hr");
+      hr.classList.add("dropdown-divider");
+      li.appendChild(hr);
+      rootNode.appendChild(li);
     }
     for (let i in accessList) {
-        const accessTextValue = accessList[i].linkLabel || accessList[i].linkId || accessList[i].ldapDn;
+        const access = accessList[i];
+      const accessTextValue = access.linkLabel || access.linkId || access.ldapDn || access.email;
 
-        let li = document.createElement("li");
-        li.classList.add("accessItem");
-        li.classList.add("dropdown-item");
-        let a = document.createElement("a");
-        let typeIcon = document.createElement("span");
-        typeIcon.innerHTML = "&nbsp;";
-        typeIcon.classList = 'type bi '+getIconForType(accessList[i]);
-        let accessText = document.createElement("span");
-        accessText.innerText = accessTextValue;
-        let logoutIcon = document.createElement("span");
-        logoutIcon.innerHTML = "&nbsp;";
-        logoutIcon.classList = 'logout bi bi-box-arrow-right'
-        a.appendChild(typeIcon);
-        a.appendChild(accessText);
-        a.appendChild(logoutIcon);
-        li.appendChild(a);
-        rootNode.appendChild(li);
-        a.addEventListener("click", async e => {
-            e.preventDefault();
-            if (!await window.confirm("Logout account " +accessTextValue +" ?"))
-                return;
-            logout(i);
-        });
+      let li = document.createElement("li");
+      li.classList.add("accessItem");
+      li.classList.add("dropdown-item");
+      let a = document.createElement("a");
+      let typeIcon = document.createElement("span");
+      typeIcon.innerHTML = "&nbsp;";
+      typeIcon.classList = "type bi " + getIconForType(access);
+      let accessText = document.createElement("span");
+      accessText.innerText = accessTextValue;
+      let logoutIcon = document.createElement("span");
+      logoutIcon.innerHTML = "&nbsp;";
+      logoutIcon.classList = "logout bi bi-box-arrow-right";
+      a.appendChild(typeIcon);
+      a.appendChild(accessText);
+      a.appendChild(logoutIcon);
+      li.appendChild(a);
+      rootNode.appendChild(li);
+      a.addEventListener("click", async (e) => {
+        e.preventDefault();
+        if (!(await window.confirm("Logout account " + accessTextValue + " ?")))
+          return;
+        logout(i);
+      });
     }
-}
-
+  };
 });

+ 32 - 2
static/public/js/uiShare.js

@@ -142,7 +142,7 @@ function buildShareItemHeader(htmlId, data, accordionBody) {
     headerButton.type = "button";
     headerButton.dataset.bsToggle = "collapse";
     headerButton.dataset.bsTarget = `#${htmlId}`;
-    headerButton.textContent = data.typeLabel || data.typeData;
+    headerButton.textContent = data.typeId === 2 ? data.typeLabel : data.typeLabel || data.typeData;
     headerButton.ariaExpanded = false;
     headerButton.ariaControls = htmlId;
     if (data.typeId === 4)
@@ -200,6 +200,31 @@ function buildShareItemLdapSpecific(htmlId, data) {
     }
 }
 
+function buildShareItemEmailSpecific(htmlId, data) {
+    if (data.typeId === 2) { // Link type
+        let typeDivRow = document.createDocumentFragment();
+        let linkContainer = document.createElement("div");
+        linkContainer.className = "container";
+        let input = document.createElement("input");
+        input.className = "form-control";
+        input.value = data.typeLabel;
+        input.type = "text";
+        input.id = `${htmlId}-linkLabel`;
+        let label = document.createElement("label");
+        label.textContent = "Email";
+        label.className = "form-label"
+        label.setAttribute("for", input.id);
+        linkContainer.appendChild(label);
+        linkContainer.appendChild(input);
+        input.addEventListener("change", async () => {
+            data.typeLabel = data.typeData = input.value.trim();
+            await updateData(data);
+        });
+        typeDivRow.appendChild(linkContainer);
+        return typeDivRow;
+    }
+}
+
 function buildShareItemLinkSpecific(htmlId, data) {
     if (data.typeId === 3) { // Link type
         let typeDivRow = document.createDocumentFragment();
@@ -359,6 +384,7 @@ async function buildShareItem(data) {
     container.appendChild(buildShareItemHeader(htmlId, data, accordionBody));
     {
         const div = buildShareItemLinkSpecific(htmlId, data)
+            || buildShareItemEmailSpecific(htmlId, data)
             || buildShareItemLdapSpecific(htmlId, data);
         div && accordionBody.appendChild(div);
     }
@@ -420,7 +446,11 @@ document.getElementById("pch-share-addLdap").addEventListener("click", async ()
         return;
     document.getElementById('pch-share-container').querySelector("ul").appendChild(await buildShareItem(await createShareData(1, "")));
 });
-document.getElementById("pch-share-addEmail").addEventListener("click", () => {});
+document.getElementById("pch-share-addEmail").addEventListener("click", async () => {
+    if (!windowDisplayed)
+        return;
+    document.getElementById('pch-share-container').querySelector("ul").appendChild(await buildShareItem(await createShareData(2, "")));
+});
 document.getElementById("pch-share-addLink").addEventListener("click", async () => {
     if (!windowDisplayed)
         return;

+ 13 - 1
templates/_login.js

@@ -15,7 +15,19 @@ module.exports = `
                             <input class="form-control" name="pass" type="password" placeholder="xxxxxx" />
                             <input type="submit" class="btn btn-primary" data-dismiss="modal"/>
                         </div>
-                    <form>
+                    </form>
+                    <div class="error alert alert-danger hidden"></div>
+                </div>
+                <hr/>
+                <div class="modal-body">
+                    <h6>Using Email Code</h6>
+                    <form method="POST" action="#">
+                        <div class="input-group" id="login-email">
+                            <input class="form-control" name="user" type="email" placeholder="email" />
+                            <input class="form-control hidden" name="code" type="text" placeholder="XXX XXX" />
+                            <input type="submit" class="btn btn-primary" data-dismiss="modal"/>
+                        </div>
+                    </form>
                     <div class="error alert alert-danger hidden"></div>
                 </div>
                 <hr/>