From 18d6ccdfd3802df121d6d66786de39d0c0834a0d Mon Sep 17 00:00:00 2001 From: yflory Date: Fri, 23 Jun 2023 17:46:14 +0200 Subject: [PATCH 001/288] SSO: OIDC auth --- .gitignore | 1 + config/sso.example.js | 21 +++ customize.dist/login.js | 33 ++++ customize.dist/pages/login.js | 2 + customize.dist/pages/register.js | 3 +- lib/challenge-commands/sso.js | 105 +++++++++++++ lib/env.js | 2 + lib/http-commands.js | 7 +- lib/http-worker.js | 11 +- lib/load-config.js | 7 + lib/plugins/sso/oidc.js | 67 +++++++++ lib/sso-utils.js | 71 +++++++++ lib/storage/sso.js | 57 +++++++ package-lock.json | 250 ++++++++++++++++--------------- package.json | 2 + www/register/main.js | 27 +++- www/ssoauth/index.html | 14 ++ www/ssoauth/main.js | 3 + 18 files changed, 557 insertions(+), 126 deletions(-) create mode 100644 config/sso.example.js create mode 100644 lib/challenge-commands/sso.js create mode 100644 lib/plugins/sso/oidc.js create mode 100644 lib/sso-utils.js create mode 100644 lib/storage/sso.js create mode 100644 www/ssoauth/index.html create mode 100644 www/ssoauth/main.js diff --git a/.gitignore b/.gitignore index 3ef8d2e15..e42df1b06 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ block/ logs/ privileged.conf config/config.js +config/sso.js *.sh diff --git a/config/sso.example.js b/config/sso.example.js new file mode 100644 index 000000000..a1b7289ab --- /dev/null +++ b/config/sso.example.js @@ -0,0 +1,21 @@ +module.exports = { + // Enable SSO login on this instance + enabled: false, + // Block registration for non-SSO users on this instance + enforced: false, + // Allow users to add an additional CryptPad password to their SSO account + cpPassword: false, + // List of SSO providers + list: [ + /* + { + name: 'google', + type: 'oidc', + url: 'https://accounts.google.com', + client_id: "{your_client_id}", + client_secret: "{your_client_secret}" + } + */ + ] +}; + diff --git a/customize.dist/login.js b/customize.dist/login.js index c8219ed0b..c0b06fa04 100644 --- a/customize.dist/login.js +++ b/customize.dist/login.js @@ -43,6 +43,39 @@ define([ setRedirectTo(); } + Exports.ssoRegister = function (provider, cb) { + var keys = Nacl.sign.keyPair(); + localStorage.CP_sso_auth = JSON.stringify({ + s: Nacl.util.encodeBase64(keys.secretKey), + p: Nacl.util.encodeBase64(keys.publicKey) + }); + ServerCommand(keys, { + command: 'SSO_AUTH', + provider: provider, + register: true + }, cb); + }; + Exports.ssoRegisterCb = function () { + var b64Keys = Util.tryParse(localStorage.CP_sso_auth); + if (!b64Keys) { + throw new Error("MISSING_SIGNATURE_KEYS"); + } + var keys = { + secretKey: Nacl.util.decodeBase64(b64Keys.s), + publicKey: Nacl.util.decodeBase64(b64Keys.p) + }; + ServerCommand(keys, { + command: 'SSO_AUTH_CB', + url: window.location.href + }, function (err, data) { + delete localStorage.CP_sso_auth; + if (data && data.state) { window.location.href = '/register/'; } + }); + }; + Exports.ssoLogin = function () { + + }; + var allocateBytes = Exports.allocateBytes = function (bytes) { var dispense = Cred.dispenser(bytes); diff --git a/customize.dist/pages/login.js b/customize.dist/pages/login.js index a4b452ffc..c6774c222 100644 --- a/customize.dist/pages/login.js +++ b/customize.dist/pages/login.js @@ -38,6 +38,8 @@ define([ undefined: h('button#register.cp-secondary', Msg.login_register) ), + h('button.login', Msg.login_login), + h('br'), h('button.login', Msg.login_login) ]) ]), diff --git a/customize.dist/pages/register.js b/customize.dist/pages/register.js index 8a0859263..44e939e72 100644 --- a/customize.dist/pages/register.js +++ b/customize.dist/pages/register.js @@ -79,7 +79,8 @@ define([ UI.createCheckbox('import-recent', Msg.register_importRecent, true) ]), termsCheck, - h('button#register', Msg.login_register) + h('button#register', Msg.login_register), + Config.sso ? h('div.cp-register-sso') : undefined ]) ]), ]) diff --git a/lib/challenge-commands/sso.js b/lib/challenge-commands/sso.js new file mode 100644 index 000000000..b00e3a89e --- /dev/null +++ b/lib/challenge-commands/sso.js @@ -0,0 +1,105 @@ +const Util = require("../common-util"); +const Commands = module.exports; +const SSOUtils = require('../sso-utils'); + +const checkConfig = (Env) => { + return Env && Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) && Env.sso.list.length; +}; +const getProviderConfig = (Env, provider) => { + if (!checkConfig) { return; } + if (!provider) { return; } + const data = Env.sso.list.find((cfg) => { return cfg.name === provider; }); + return data; +}; + +const TYPES = SSOUtils.TYPES; + +const isValidConfig = (cfg) => { + if (!cfg) { return; } + if (!cfg.type) { return; } + const type = cfg.type.toLowerCase(); + const idp = TYPES[type]; + if (!idp) { return; } + return idp.checkConfig(cfg); +}; + +const auth = Commands.SSO_AUTH = function (Env, body, cb) { + if (!checkConfig(Env)) { return void cb('INVALID_SERVER_CONFIG'); } + const { provider } = body; + const cfg = getProviderConfig(Env, provider); + if (!cfg) { return void cb('UNRECOGNIZED_IDP'); } + if (!isValidConfig(cfg)) { return void cb('INVALID_IDP_CONFIG'); } + + cb(); +}; +auth.complete = function (Env, body, cb, req, res) { + // If we're here it means a valid provider was given. We can start the authentication process + const { provider, register, publicKey } = body; + const cfg = getProviderConfig(Env, provider); + const idp = TYPES[cfg.type.toLowerCase()]; + idp.auth(Env, cfg, (err, obj) => { + if (err) { return void cb(err); } // TODO log + + const { url, token } = obj; + + SSOUtils.writeRequest(Env, { + id: token, + type: idp.type, + provider: provider, + publicKey: publicKey, + register: Boolean(register) + }, (err) => { + if (err) { return void cb("E_REQ_WRITE"); } + + let value = `ssotoken="${token}"; SameSite=Strict; HttpOnly`; + res.setHeader('Set-Cookie', value); + cb(void 0, {url: url}); + }); + }); +}; + +const authCb = Commands.SSO_AUTH_CB = function (Env, body, cb, req) { + if (!checkConfig(Env)) { return void cb('INVALID_SERVER_CONFIG'); } + const { publicKey } = body; + const cookies = req.cookies; + const ssotoken = cookies.ssotoken; + if (!ssotoken) { return void cb('NO_COOKIE'); } + SSOUtils.readRequest(Env, ssotoken, (err, value) => { + if (err) { return void cb('ENOENT'); } + const data = Util.tryParse(value); + if (!data || !data.type || !TYPES[data.type]) { return void cb('INVALID_REQUEST_DATA'); } + console.log('stored', data.publicKey); + console.log('used', publicKey); + if (publicKey !== data.publicKey) { return void cb('WRONG_SIGNATURE_KEYS'); } + cb(); + }); +}; +authCb.complete = function (Env, body, cb, req) { + // If we're here it means a valid cookie was given. We can continue the authentication + const { url } = body; + const cookies = req.cookies; + const ssotoken = cookies.ssotoken; + if (!ssotoken) { return void cb('NO_COOKIE'); } + SSOUtils.readRequest(Env, ssotoken, (err, value) => { + SSOUtils.deleteRequest(Env, ssotoken); + if (err) { return void cb('ENOENT'); } + const data = Util.tryParse(value); + const cfg = getProviderConfig(Env, data.provider); + const idp = TYPES[data.type]; + idp.authCb(Env, cfg, ssotoken, url, (err, obj) => { + if (err) { return void cb(err); } + const {id, idpData} = obj; + console.log(id, idpData); + SSOUtils.makeUser(Env, id, (err, userData) => { + if (err) { return void cb(err); } + + // TODO + // makeTempSession() + + cb(void 0, { state: true }); + }); + + }); + }); +}; + diff --git a/lib/env.js b/lib/env.js index a567b39d4..68591e8ec 100644 --- a/lib/env.js +++ b/lib/env.js @@ -223,6 +223,8 @@ module.exports.create = function (config) { evictionReport: {}, commandTimers: {}, + sso: config.sso, + // initialized as undefined bearerSecret: void 0, curvePrivate: curve.secretKey, diff --git a/lib/http-commands.js b/lib/http-commands.js index 229c27e8b..2c19de250 100644 --- a/lib/http-commands.js +++ b/lib/http-commands.js @@ -73,6 +73,9 @@ COMMANDS.TOTP_REVOKE = TOTP.TOTP_REVOKE; COMMANDS.TOTP_WRITE_BLOCK = TOTP.TOTP_WRITE_BLOCK; COMMANDS.TOTP_REMOVE_BLOCK = TOTP.TOTP_REMOVE_BLOCK; +const SSO = require("./challenge-commands/sso.js"); +COMMANDS.SSO_AUTH = SSO.SSO_AUTH; +COMMANDS.SSO_AUTH_CB = SSO.SSO_AUTH_CB; var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-'); @@ -141,7 +144,7 @@ var handleCommand = function (Env, req, res) { date: date, }); }); - }); + }, req); } catch (err) { Env.Log.error("CHALLENGE_COMMAND_THROWN_ERROR", { error: Util.serializeError(err), @@ -290,7 +293,7 @@ var handleResponse = function (Env, req, res) { }); } res.status(200).json(content); - }); + }, req, res); }); }; diff --git a/lib/http-worker.js b/lib/http-worker.js index 77cb6909b..8047cdb0d 100644 --- a/lib/http-worker.js +++ b/lib/http-worker.js @@ -10,6 +10,7 @@ const AuthCommands = require("./http-commands"); const JWT = require("jsonwebtoken"); const MFA = require("./storage/mfa"); const Sessions = require("./storage/sessions"); +const cookieParser = require("cookie-parser"); const DEFAULT_QUERY_TIMEOUT = 5000; const PID = process.pid; @@ -158,6 +159,7 @@ var setHeaders = function (req, res) { const Express = require("express"); var app = Express(); +app.use(cookieParser()); (function () { if (!Env.logFeedback) { return; } @@ -187,7 +189,6 @@ const wsProxy = createProxyMiddleware({ app.use('/cryptpad_websocket', wsProxy); - app.use('/blob', function (req, res, next) { /* Head requests are used to check the size of a blob. Clients can configure a maximum size to download automatically, @@ -484,6 +485,13 @@ var makeRouteCache = function (template, cacheName) { }; }; +const ssoList = Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) && + Env.sso.list.map(function (obj) { return obj.name; }); +const ssoCfg = ssoList.length ? { + force: (Env.sso && Env.sso.enforced && 1) || 0, + password: (Env.sso && Env.sso.cpPassword && 1) || 0, + list: ssoList +} : false; var serveConfig = makeRouteCache(function () { return [ 'define(function(){', @@ -510,6 +518,7 @@ var serveConfig = makeRouteCache(function () { shouldUpdateNode: Env.shouldUpdateNode || undefined, listMyInstance: Env.listMyInstance, accounts_api: Env.accounts_api, + sso: ssoCfg }, null, '\t'), '});' ].join(';\n'); diff --git a/lib/load-config.js b/lib/load-config.js index 49dbbf58a..a847f812e 100644 --- a/lib/load-config.js +++ b/lib/load-config.js @@ -42,5 +42,12 @@ if (!isPositiveNumber(config.premiumUploadSize) || config.premiumUploadSize < co delete config.premiumUploadSize; } +config.sso = {}; +try { + config.sso = require("../config/sso"); +} catch (e) { + console.log("SSO config not found"); +} + module.exports = config; diff --git a/lib/plugins/sso/oidc.js b/lib/plugins/sso/oidc.js new file mode 100644 index 000000000..a61ce0846 --- /dev/null +++ b/lib/plugins/sso/oidc.js @@ -0,0 +1,67 @@ +const OID = require('openid-client'); + +const TYPE = 'oidc'; + +const getClient = (cfg, cb) => { + OID.Issuer.discover(cfg.url).then((issuer) => { // XXX Only once for all users? + console.log('Discovered issuer %s %O', issuer.issuer); + const client = new issuer.Client({ + client_id: cfg.client_id, + client_secret: cfg.client_secret, + redirect_uris: ['http://localhost:3000/ssoauth'], // XXX Use httpUnsafeOrigin or... + response_types: ['code'], + }); + cb(void 0, client); + }, (err) => { + cb(err); + }); +}; +module.exports = { + type: TYPE, + checkConfig: (cfg) => { + return cfg.url && cfg.client_id && cfg.client_secret; + }, + auth: (Env, cfg, cb) => { + getClient(cfg, (err, client) => { + if (err) { return void cb ('E_OIDC_CONNECT'); } + + const generators = OID.generators; + const code_verifier = generators.codeVerifier(); + // store the code_verifier in your framework's session mechanism, if it is a cookie based solution + // it should be httpOnly (not readable by javascript) and encrypted. + + console.log(code_verifier); + const code_challenge = generators.codeChallenge(code_verifier); + const url = client.authorizationUrl({ + scope: 'openid email profile',// https://www.googleapis.com/auth/contacts.readonly', // https://www.google.com/m8/feeds + resource: 'http://localhost:3000/ssoauth/', + access_type: 'offline', + code_challenge, + code_challenge_method: 'S256', + }); + + cb(void 0, { url: url, token: code_verifier }); + }); + }, + authCb: (Env, cfg, token, url, cb) => { + getClient(cfg, (err, client) => { + if (err) { return void cb ('E_OIDC_CONNECT'); } + + const params = client.callbackParams(url); + client.callback('http://localhost:3000/ssoauth', params, { code_verifier: token }) + .then((tokenSet) => { + let j = tokenSet; + let c = tokenSet.claims(); + console.log(j, c); + cb(void 0, { + id: c.sub, + idpData: { + access_token: j.access_token, + refresh_token: j.refresh_token, + id_token: j.id_token + } + }); + }); + }); + } +}; diff --git a/lib/sso-utils.js b/lib/sso-utils.js new file mode 100644 index 000000000..1bad43d5d --- /dev/null +++ b/lib/sso-utils.js @@ -0,0 +1,71 @@ +const SSO = require("./storage/sso"); +const Nacl = require("tweetnacl/nacl-fast"); + +const SSOUtils = module.exports; + +// XXX const SAML = require('node-saml'); // https://www.npmjs.com/package/node-saml +SSOUtils.TYPES = { + oidc: require('./plugins/sso/oidc') +}; + + +SSOUtils.deleteRequest = (Env, id) => { + SSO.request.delete(Env, id, (err) => { + if (!err) { return; } + console.log(`Failed to delete SSO request ${id}`); + // XXX log? + }); +}; +SSOUtils.readRequest = (Env, id, cb) => { + SSO.request.read(Env, id, cb); +}; +SSOUtils.writeRequest = (Env, data, cb) => { + if (!data || !data.id || !data.type) { return void cb ('INVALID_REQUEST'); } + const id = data.id; + const value = { + type: data.type, + register: data.register, + provider: data.provider, + publicKey: data.publicKey, + time: +new Date() + }; + + SSO.request.write(Env, id, JSON.stringify(value), cb); +}; + + +SSOUtils.makeUser = (Env, id, cb) => { + const seed = Nacl.util.encodeBase64(Nacl.util.randomBytes(24)); + SSO.User.write(Env, id, JSON.stringify({ + seed: seed, + password: false // XXX maybe we don't need that flag, we can check if the block exists later + }), (err) => { + if (err) { return void cb(err); } + cb(void 0, { seed }); + }); + +}; + +SSOUtils.makeSession = (Env, publicKey, provider, ssoData, cb) => { + const sessionId = Sessions.randomId(); + // XXX If we already have an OTP session, recover it + Sessions.write(Env, publicKey, sessionId, JSON.stringify({ + sso: { + provider: provider, + data: ssoData + } + }), function (err) { + if (err) { + Env.Log.error("SSO_SESSION_WRITE", { + error: Util.serializeError(err), + publicKey: publicKey, + sessionId: sessionId, + }); + return void cb("SESSION_WRITE_ERROR"); + } + cb(void 0, { + bearer: sessionId, + }); + }); + +}; diff --git a/lib/storage/sso.js b/lib/storage/sso.js new file mode 100644 index 000000000..65110ee30 --- /dev/null +++ b/lib/storage/sso.js @@ -0,0 +1,57 @@ +const Basic = require("./basic"); +const Path = require("node:path"); +const Util = require("../common-util"); + +const SSO = module.exports; +/* This module manages storage related to Single Sign-On (SSO) settings. + +A first part (sso-requests) contains temporary files for sso authentication with a remote service +and a second part (sso users) is a database of accounts registered via SSO. + +The path for requests is based on the "authentication request" token depending on the type of SSO. +The path for the user database is based on their persistent identifier (id) from the SSO. +*/ + +var pathFromId = function (Env, id, subPath) { + if (!id || typeof(id) !== 'string') { return; } + id = Util.escapeKeyCharacters(id); + return Path.join(Env.paths.base, subPath, id.slice(0, 2), `${id}.json`); +}; +var reqPathFromId = function (Env, id) { + return pathFromId(Env, id, 'sso_request'); +}; +var userPathFromId = function (Env, id) { + return pathFromId(Env, id, 'sso'); +}; + +const Req = SSO.request = {}; + +Req.read = function (Env, id, cb) { + var path = reqPathFromId(Env, id); + Basic.read(Env, path, cb); +}; +Req.write = function (Env, id, data, cb) { + var path = reqPathFromId(Env, id); + console.log(path); + Basic.write(Env, path, data, cb); +}; +Req.delete = function (Env, id, cb) { + var path = reqPathFromId(Env, id); + Basic.delete(Env, path, cb); +}; + + +const User = SSO.user = {}; + +User.read = function (Env, id, cb) { + var path = userPathFromId(Env, id); + Basic.read(Env, path, cb); +}; +User.write = function (Env, id, data, cb) { + var path = userPathFromId(Env, id); + Basic.write(Env, path, data, cb); +}; +User.delete = function (Env, id, cb) { + var path = userPathFromId(Env, id); + Basic.delete(Env, path, cb); +}; diff --git a/package-lock.json b/package-lock.json index d97d0d112..e6b12b5bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@mcrowe/minibloom": "^0.2.0", "chainpad-crypto": "^0.2.5", "chainpad-server": "^5.1.0", + "cookie-parser": "^1.4.6", "express": "~4.18.2", "fs-extra": "^7.0.0", "get-folder-size": "^2.0.1", @@ -20,6 +21,7 @@ "netflux-websocket": "^0.1.20", "notp": "^2.0.3", "nthen": "0.1.8", + "openid-client": "^5.4.2", "prompt-confirm": "^2.0.4", "pull-stream": "^3.6.1", "saferphore": "0.0.1", @@ -1011,6 +1013,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -1749,25 +1771,15 @@ } }, "node_modules/glob-parent": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", - "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "dependencies": { - "is-glob": "^3.1.0", - "path-dirname": "^1.0.0" - } - }, - "node_modules/glob-parent/node_modules/is-glob": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.0" + "is-glob": "^4.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">= 6" } }, "node_modules/glob-to-regexp": { @@ -2220,6 +2232,14 @@ "node": ">=0.10.0" } }, + "node_modules/jose": { + "version": "4.14.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", + "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", @@ -2727,6 +2747,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -2758,6 +2786,14 @@ "node": ">=0.10.0" } }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2778,6 +2814,20 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.4.2.tgz", + "integrity": "sha512-lIhsdPvJ2RneBm3nGBBhQchpe3Uka//xf7WPHTIglery8gnckvW7Bd9IaQzekzXJvWthCMyi/xVEyGW0RFPytw==", + "dependencies": { + "jose": "^4.14.1", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", @@ -2808,12 +2858,6 @@ "node": ">=0.10.0" } }, - "node_modules/path-dirname": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", - "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", - "dev": true - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -3039,17 +3083,6 @@ "node": ">=0.10.0" } }, - "node_modules/prompt-choices/node_modules/set-value": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-3.0.3.tgz", - "integrity": "sha512-Xsn/XSatoVOGBbp5hs3UylFDs5Bi9i+ArpVJKdHPniZHoEgRniXTqHWrWrGQ0PbEClVT6WtfnBwR8CAHC9sveg==", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=6.0" - } - }, "node_modules/prompt-choices/node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -3391,39 +3424,19 @@ } }, "node_modules/set-value": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", - "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", - "dev": true, + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-4.0.1.tgz", + "integrity": "sha512-ayATicCYPVnlNpFmjq2/VmVwhoCQA9+13j8qWp044fmFE3IFphosPtRM+0CJ5xoIx5Uy52fCcwg3XeH2pHbbPQ==", + "funding": [ + "https://github.com/sponsors/jonschlinkert", + "https://paypal.me/jonathanschlinkert", + "https://jonschlinkert.dev/sponsor" + ], "dependencies": { - "extend-shallow": "^2.0.1", - "is-extendable": "^0.1.1", - "is-plain-object": "^2.0.3", - "split-string": "^3.0.1" + "is-plain-object": "^2.0.4" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/set-value/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/set-value/node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", - "dev": true, - "engines": { - "node": ">=0.10.0" + "node": ">=11.0" } }, "node_modules/setprototypeof": { @@ -4738,7 +4751,7 @@ "get-value": "^2.0.6", "has-value": "^1.0.0", "isobject": "^3.0.1", - "set-value": "^2.0.0", + "set-value": "4.0.1", "to-object-path": "^0.3.0", "union-value": "^1.0.0", "unset-value": "^1.0.0" @@ -4980,6 +4993,22 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -5387,7 +5416,7 @@ "requires": { "@mrmlnc/readdir-enhanced": "^2.2.1", "@nodelib/fs.stat": "^1.1.2", - "glob-parent": "^3.1.0", + "glob-parent": "5.1.2", "is-glob": "^4.0.0", "merge2": "^1.2.3", "micromatch": "^3.1.10" @@ -5556,24 +5585,12 @@ } }, "glob-parent": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", - "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "requires": { - "is-glob": "^3.1.0", - "path-dirname": "^1.0.0" - }, - "dependencies": { - "is-glob": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", - "dev": true, - "requires": { - "is-extglob": "^2.1.0" - } - } + "is-glob": "^4.0.1" } }, "glob-to-regexp": { @@ -5907,6 +5924,11 @@ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, + "jose": { + "version": "4.14.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", + "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==" + }, "js-yaml": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", @@ -6309,6 +6331,11 @@ } } }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" + }, "object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -6331,6 +6358,11 @@ "isobject": "^3.0.1" } }, + "oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==" + }, "on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6348,6 +6380,17 @@ "wrappy": "1" } }, + "openid-client": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.4.2.tgz", + "integrity": "sha512-lIhsdPvJ2RneBm3nGBBhQchpe3Uka//xf7WPHTIglery8gnckvW7Bd9IaQzekzXJvWthCMyi/xVEyGW0RFPytw==", + "requires": { + "jose": "^4.14.1", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + } + }, "parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", @@ -6369,12 +6412,6 @@ "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", "dev": true }, - "path-dirname": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", - "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", - "dev": true - }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -6528,7 +6565,7 @@ "log-utils": "^0.2.1", "pointer-symbol": "^1.0.0", "radio-symbol": "^2.0.0", - "set-value": "^3.0.0", + "set-value": "4.0.1", "strip-color": "^0.1.0", "terminal-paginator": "^2.0.2", "toggle-array": "^1.0.1" @@ -6549,14 +6586,6 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz", "integrity": "sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==" }, - "set-value": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-3.0.3.tgz", - "integrity": "sha512-Xsn/XSatoVOGBbp5hs3UylFDs5Bi9i+ArpVJKdHPniZHoEgRniXTqHWrWrGQ0PbEClVT6WtfnBwR8CAHC9sveg==", - "requires": { - "is-plain-object": "^2.0.4" - } - }, "shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -6836,32 +6865,11 @@ } }, "set-value": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", - "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", - "dev": true, + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-4.0.1.tgz", + "integrity": "sha512-ayATicCYPVnlNpFmjq2/VmVwhoCQA9+13j8qWp044fmFE3IFphosPtRM+0CJ5xoIx5Uy52fCcwg3XeH2pHbbPQ==", "requires": { - "extend-shallow": "^2.0.1", - "is-extendable": "^0.1.1", - "is-plain-object": "^2.0.3", - "split-string": "^3.0.1" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", - "dev": true - } + "is-plain-object": "^2.0.4" } }, "setprototypeof": { @@ -7351,7 +7359,7 @@ "arr-union": "^3.1.0", "get-value": "^2.0.6", "is-extendable": "^0.1.1", - "set-value": "^2.0.1" + "set-value": "4.0.1" }, "dependencies": { "is-extendable": { diff --git a/package.json b/package.json index 5546c1d1c..cda1e4183 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@mcrowe/minibloom": "^0.2.0", "chainpad-crypto": "^0.2.5", "chainpad-server": "^5.1.0", + "cookie-parser": "^1.4.6", "express": "~4.18.2", "fs-extra": "^7.0.0", "get-folder-size": "^2.0.1", @@ -23,6 +24,7 @@ "netflux-websocket": "^0.1.20", "notp": "^2.0.3", "nthen": "0.1.8", + "openid-client": "^5.4.2", "prompt-confirm": "^2.0.4", "pull-stream": "^3.6.1", "saferphore": "0.0.1", diff --git a/www/register/main.js b/www/register/main.js index d772c770f..3900fc855 100644 --- a/www/register/main.js +++ b/www/register/main.js @@ -1,4 +1,5 @@ define([ + '/api/config', 'jquery', '/customize/login.js', '/common/cryptpad-common.js', @@ -14,7 +15,7 @@ define([ '/customize/pages.js', 'css!/bower_components/components-font-awesome/css/font-awesome.min.css', -], function ($, Login, Cryptpad, /*Test,*/ Cred, UI, Util, Realtime, Constants, Feedback, LocalStore, h, Pages) { +], function (Config, $, Login, Cryptpad, /*Test,*/ Cred, UI, Util, Realtime, Constants, Feedback, LocalStore, h, Pages) { if (window.top !== window) { return; } var Messages = Cryptpad.Messages; $(function () { @@ -50,6 +51,30 @@ define([ var br = function () { return h('br'); }; Messages.register_nameTooLong = "Usernames must be shorter than {0} characters"; // XXX + if (Config.sso) { + // TODO + // Config.sso.force => no legacy login allowed + // Config.sso.password => cp password required or forbidden + // Config.sso.list => list of configured identity providers + var $sso = $('div.cp-register-sso'); + var list = Config.sso.list.map(function (name) { + var b = h('button.btn.btn-secondary', name); + var $b = $(b).click(function () { + console.log('sso register click:', name); + $b.prop('disabled', 'disabled'); + Login.ssoRegister(name, function (err, data) { + console.error(err, data); + if (data.url) { + window.location.href = data.url; + } + }); + // XXX Server command + }); + return b; + }); + $sso.append(list); + } + var registerClick = function () { var uname = $uname.val().trim(); // trim whitespace surrounding the username since it is otherwise included in key derivation diff --git a/www/ssoauth/index.html b/www/ssoauth/index.html new file mode 100644 index 000000000..a7bdb9b14 --- /dev/null +++ b/www/ssoauth/index.html @@ -0,0 +1,14 @@ + + + + + CryptPad: Collaboration suite, encrypted and open-source + + + + + + + + + diff --git a/www/ssoauth/main.js b/www/ssoauth/main.js new file mode 100644 index 000000000..c556e2814 --- /dev/null +++ b/www/ssoauth/main.js @@ -0,0 +1,3 @@ +define(['/customize/login.js'], function (Login) { + Login.ssoRegisterCb(); +}); From b93b5eae4e10150efcd4e184d9800fe5f9db37ef Mon Sep 17 00:00:00 2001 From: yflory Date: Tue, 27 Jun 2023 16:04:32 +0200 Subject: [PATCH 002/288] SSO: OIDC login and register --- customize.dist/login.js | 529 +----------------- customize.dist/pages/login.js | 5 +- customize.dist/pages/ssoauth.js | 57 ++ .../src/less2/pages/page-ssoauth.less | 29 + customize.dist/template.js | 2 + lib/challenge-commands/sso.js | 182 +++++- lib/challenge-commands/totp.js | 36 +- lib/http-commands.js | 4 +- lib/http-worker.js | 151 ++--- lib/plugins/sso/oidc.js | 27 +- lib/sso-utils.js | 107 +++- lib/storage/sessions.js | 8 + lib/storage/sso.js | 23 +- www/common/common-login.js | 520 +++++++++++++++++ www/common/outer/login-block.js | 15 +- www/install/main.js | 3 +- www/login/main.js | 25 +- www/register/main.js | 7 +- www/ssoauth/index.html | 4 +- www/ssoauth/main.js | 143 ++++- 20 files changed, 1171 insertions(+), 706 deletions(-) create mode 100644 customize.dist/pages/ssoauth.js create mode 100644 customize.dist/src/less2/pages/page-ssoauth.less create mode 100644 www/common/common-login.js diff --git a/customize.dist/login.js b/customize.dist/login.js index c0b06fa04..fb9d01365 100644 --- a/customize.dist/login.js +++ b/customize.dist/login.js @@ -4,6 +4,7 @@ define([ '/bower_components/chainpad-crypto/crypto.js', '/common/common-util.js', '/common/outer/network-config.js', + '/common/common-login.js', '/common/common-credential.js', '/bower_components/chainpad/chainpad.dist.js', '/common/common-realtime.js', @@ -19,14 +20,14 @@ define([ '/bower_components/tweetnacl/nacl-fast.min.js', '/bower_components/scrypt-async/scrypt-async.min.js', // better load speed -], function ($, Listmap, Crypto, Util, NetConfig, Cred, ChainPad, Realtime, Constants, UI, +], function ($, Listmap, Crypto, Util, NetConfig, Login, Cred, ChainPad, Realtime, Constants, UI, Feedback, LocalStore, Messages, nThen, Block, Hash, ServerCommand) { var Exports = { Cred: Cred, Block: Block, // this is depended on by non-customizable files // be careful when modifying login.js - requiredBytes: 192, + requiredBytes: Login.requiredBytes, }; var Nacl = window.nacl; @@ -39,11 +40,9 @@ define([ redirectTo = newPad.href; } }; - if (window.location.hash) { - setRedirectTo(); - } + if (window.location.hash) { setRedirectTo(); } - Exports.ssoRegister = function (provider, cb) { + Exports.ssoAuth = function (provider, cb) { var keys = Nacl.sign.keyPair(); localStorage.CP_sso_auth = JSON.stringify({ s: Nacl.util.encodeBase64(keys.secretKey), @@ -55,518 +54,17 @@ define([ register: true }, cb); }; - Exports.ssoRegisterCb = function () { - var b64Keys = Util.tryParse(localStorage.CP_sso_auth); - if (!b64Keys) { - throw new Error("MISSING_SIGNATURE_KEYS"); - } - var keys = { - secretKey: Nacl.util.decodeBase64(b64Keys.s), - publicKey: Nacl.util.decodeBase64(b64Keys.p) - }; - ServerCommand(keys, { - command: 'SSO_AUTH_CB', - url: window.location.href - }, function (err, data) { - delete localStorage.CP_sso_auth; - if (data && data.state) { window.location.href = '/register/'; } - }); - }; Exports.ssoLogin = function () { }; - var allocateBytes = Exports.allocateBytes = function (bytes) { - var dispense = Cred.dispenser(bytes); + Exports.allocateBytes = Login.allocateBytes; + Exports.loadUserObject = Login.loadUserObject; - var opt = {}; - - // dispense 18 bytes of entropy for your encryption key - var encryptionSeed = dispense(18); - // 16 bytes for a deterministic channel key - var channelSeed = dispense(16); - // 32 bytes for a curve key - var curveSeed = dispense(32); - - var curvePair = Nacl.box.keyPair.fromSecretKey(new Uint8Array(curveSeed)); - opt.curvePrivate = Nacl.util.encodeBase64(curvePair.secretKey); - opt.curvePublic = Nacl.util.encodeBase64(curvePair.publicKey); - - // 32 more for a signing key - var edSeed = opt.edSeed = dispense(32); - - // 64 more bytes to seed an additional signing key - var blockKeys = opt.blockKeys = Block.genkeys(new Uint8Array(dispense(64))); - opt.blockHash = Block.getBlockHash(blockKeys); - - // derive a private key from the ed seed - var signingKeypair = Nacl.sign.keyPair.fromSeed(new Uint8Array(edSeed)); - - opt.edPrivate = Nacl.util.encodeBase64(signingKeypair.secretKey); - opt.edPublic = Nacl.util.encodeBase64(signingKeypair.publicKey); - - var keys = opt.keys = Crypto.createEditCryptor(null, encryptionSeed); - - // 24 bytes of base64 - keys.editKeyStr = keys.editKeyStr.replace(/\//g, '-'); - - // 32 bytes of hex - var channelHex = opt.channelHex = Util.uint8ArrayToHex(channelSeed); - - // should never happen - if (channelHex.length !== 32) { throw new Error('invalid channel id'); } - - var channel64 = Util.hexToBase64(channelHex); - - // we still generate a v1 hash because this function needs to deterministically - // derive the same values as it always has. New accounts will generate their own - // userHash values - opt.userHash = '/1/edit/' + [channel64, opt.keys.editKeyStr].join('/') + '/'; - - return opt; + var setMergeAnonDrive = function (value) { + Exports.mergeAnonDrive = Boolean(value); }; - - var loginOptionsFromBlock = Exports.loginOptionsFromBlock = function (blockInfo) { - var opt = {}; - var parsed = Hash.getSecrets('pad', blockInfo.User_hash); - opt.channelHex = parsed.channel; - opt.keys = parsed.keys; - opt.edPublic = blockInfo.edPublic; - return opt; - }; - - var loadUserObject = Exports.loadUserObject = function (opt, cb) { - var config = { - websocketURL: NetConfig.getWebsocketURL(), - channel: opt.channelHex, - data: {}, - validateKey: opt.keys.validateKey, // derived validation key - crypto: Crypto.createEncryptor(opt.keys), - logLevel: 1, - classic: true, - ChainPad: ChainPad, - owners: [opt.edPublic] - }; - - var rt = opt.rt = Listmap.create(config); - rt.proxy - .on('ready', function () { - setTimeout(function () { cb(void 0, rt); }); - }) - .on('disconnect', function (info) { - cb('E_DISCONNECT', info); - }); - }; - - var isProxyEmpty = function (proxy) { - var l = Object.keys(proxy).length; - return l === 0 || (l === 2 && proxy._events && proxy.on); - }; - - var setMergeAnonDrive = function () { - Exports.mergeAnonDrive = 1; - }; - - Exports.loginOrRegister = function (uname, passwd, isRegister, shouldImport, onOTP, cb) { - if (typeof(cb) !== 'function') { return; } - - // Usernames are all lowercase. No going back on this one - uname = uname.toLowerCase(); - - // validate inputs - if (!Cred.isValidUsername(uname)) { return void cb('INVAL_USER'); } - if (!Cred.isValidPassword(passwd)) { return void cb('INVAL_PASS'); } - if (isRegister && !Cred.isLongEnoughPassword(passwd)) { - return void cb('PASS_TOO_SHORT'); - } - - // results... - var res = { - register: isRegister, - }; - - var RT, blockKeys, blockHash, blockUrl, Pinpad, rpc, userHash; - - nThen(function (waitFor) { - // derive a predefined number of bytes from the user's inputs, - // and allocate them in a deterministic fashion - Cred.deriveFromPassphrase(uname, passwd, Exports.requiredBytes, waitFor(function (bytes) { - res.opt = allocateBytes(bytes); - blockHash = res.opt.blockHash; - blockKeys = res.opt.blockKeys; - })); - }).nThen(function (waitFor) { - // the allocated bytes can be used either in a legacy fashion, - // or in such a way that a previously unused byte range determines - // the location of a layer of indirection which points users to - // an encrypted block, from which they can recover the location of - // the rest of their data - - // determine where a block for your set of keys would be stored - blockUrl = Block.getBlockUrl(res.opt.blockKeys); - - var TOTP_prompt = function (err, cb) { - onOTP(function (code) { - ServerCommand(res.opt.blockKeys.sign, { - command: 'TOTP_VALIDATE', - code: code, - // TODO optionally allow the user to specify a lifetime for this session? - // this will require a little bit of server work - // and more UI/UX: - // ie. just a simple "remember me" checkbox? - // allow them to specify a lifetime for the session? - // "log me out after one day"? - }, cb); - }, false, err); - }; - - var done = waitFor(); - var responseToDecryptedBlock = function (response, cb) { - response.arrayBuffer().then(arraybuffer => { - arraybuffer = new Uint8Array(arraybuffer); - var decryptedBlock = Block.decrypt(arraybuffer, blockKeys); - if (!decryptedBlock) { - console.error("BLOCK DECRYPTION ERROR"); - return void cb("BLOCK_DECRYPTION_ERROR"); - } - cb(void 0, decryptedBlock); - }); - }; - - var TOTP_response; - nThen(function (w) { - Util.getBlock(blockUrl, { - // request the block without credentials - }, w(function (err, response) { - if (err === 401) { - return void console.log("Block requires 2FA"); - } - - // Some other error? - if (err) { - console.error(err); - w.abort(); - return void done(); - } - - // If the block was returned without requiring authentication - // then we can abort the subsequent steps of this nested nThen - w.abort(); - - // decrypt the response and continue the normal procedure with its payload - responseToDecryptedBlock(response, function (err, decryptedBlock) { - if (err) { - // if a block was present but you were not able to decrypt it... - console.error(err); - waitFor.abort(); - return void cb(err); - } - res.blockInfo = decryptedBlock; - done(); - }); - })); - }).nThen(function (w) { - // if you're here then you need to request a JWT - var done = w(); - var tries = 3; - var ask = function () { - if (!tries) { - w.abort(); - waitFor.abort(); - return void cb('TOTP_ATTEMPTS_EXHAUSTED'); - } - tries--; - TOTP_prompt(tries !== 2, function (err, response) { - // ask again until your number of tries are exhausted - if (err) { - console.error(err); - console.log("Normal failure. Asking again..."); - return void ask(); - } - if (!response || !response.bearer) { - console.log(response); - console.log("Unexpected failure. No bearer token. Asking again"); - return void ask(); - } - console.log("Successfully retrieved a bearer token"); - res.TOTP_token = TOTP_response = response; - done(); - }); - }; - ask(); - }).nThen(function (w) { - Util.getBlock(blockUrl, TOTP_response, function (err, response) { - if (err) { - w.abort(); - console.error(err); - return void cb('BLOCK_ERROR_3'); - } - - responseToDecryptedBlock(response, function (err, decryptedBlock) { - if (err) { - waitFor.abort(); - return void cb(err); - } - res.blockInfo = decryptedBlock; - done(); - }); - }); - }); - }).nThen(function (waitFor) { - // we assume that if there is a block, it was created in a valid manner - // so, just proceed to the next block which handles that stuff - if (res.blockInfo) { return; } - - var opt = res.opt; - - // load the user's object using the legacy credentials - loadUserObject(opt, waitFor(function (err, rt) { - if (err) { - waitFor.abort(); - return void cb(err); - } - - // if a proxy is marked as deprecated, it is because someone had a non-owned drive - // but changed their password, and couldn't delete their old data. - // if they are here, they have entered their old credentials, so we should not - // allow them to proceed. In time, their old drive should get deleted, since - // it will should be pinned by anyone's drive. - if (rt.proxy[Constants.deprecatedKey]) { - waitFor.abort(); - return void cb('NO_SUCH_USER', res); - } - - if (isRegister && isProxyEmpty(rt.proxy)) { - // If they are trying to register, - // and the proxy is empty, then there is no 'legacy user' either - // so we should just shut down this session and disconnect. - //rt.network.disconnect(); - return; // proceed to the next async block - } - - // they tried to just log in but there's no such user - // and since we're here at all there is no modern-block - if (!isRegister && isProxyEmpty(rt.proxy)) { - //rt.network.disconnect(); // clean up after yourself - waitFor.abort(); - return void cb('NO_SUCH_USER', res); - } - - // they tried to register, but those exact credentials exist - if (isRegister && !isProxyEmpty(rt.proxy)) { - //rt.network.disconnect(); - waitFor.abort(); - Feedback.send('LOGIN', true); - return void cb('ALREADY_REGISTERED', res); - } - - // if you are here, then there is no block, the user is trying - // to log in. The proxy is **not** empty. All values assigned here - // should have been deterministically created using their credentials - // so setting them is just a precaution to keep things in good shape - res.proxy = rt.proxy; - res.realtime = rt.realtime; - res.network = rt.network; - - // they're registering... - res.userHash = opt.userHash; - res.userName = uname; - - // export their signing key - res.edPrivate = opt.edPrivate; - res.edPublic = opt.edPublic; - - // export their encryption key - res.curvePrivate = opt.curvePrivate; - res.curvePublic = opt.curvePublic; - - if (shouldImport) { setMergeAnonDrive(); } - - // don't proceed past this async block. - waitFor.abort(); - - // We have to call whenRealtimeSyncs asynchronously here because in the current - // version of listmap, onLocal calls `chainpad.contentUpdate(newValue)` - // asynchronously. - // The following setTimeout is here to make sure whenRealtimeSyncs is called after - // `contentUpdate` so that we have an update userDoc in chainpad. - setTimeout(function () { - Realtime.whenRealtimeSyncs(rt.realtime, function () { - // the following stages are there to initialize a new drive - // if you are registering - LocalStore.login(res.userHash, undefined, res.userName, function () { - setTimeout(function () { cb(void 0, res); }); - }); - }); - }); - })); - }).nThen(function (waitFor) { // MODERN REGISTRATION / LOGIN - var opt; - if (res.blockInfo) { - opt = loginOptionsFromBlock(res.blockInfo); - userHash = res.blockInfo.User_hash; - //console.error(opt, userHash); - } else { - console.log("allocating random bytes for a new user object"); - opt = allocateBytes(Nacl.randomBytes(Exports.requiredBytes)); - // create a random v2 hash, since we don't need backwards compatibility - userHash = opt.userHash = Hash.createRandomHash('drive'); - var secret = Hash.getSecrets('drive', userHash); - opt.keys = secret.keys; - opt.channelHex = secret.channel; - } - - // according to the location derived from the credentials which you entered - loadUserObject(opt, waitFor(function (err, rt) { - if (err) { - waitFor.abort(); - return void cb('MODERN_REGISTRATION_INIT'); - } - - //console.error(JSON.stringify(rt.proxy)); - - // export the realtime object you checked - RT = rt; - - var proxy = rt.proxy; - if (isRegister && !isProxyEmpty(proxy) && (!proxy.edPublic || !proxy.edPrivate)) { - console.error("INVALID KEYS"); - console.log(JSON.stringify(proxy)); - return; - } - - res.proxy = rt.proxy; - res.realtime = rt.realtime; - res.network = rt.network; - - // they're registering... - res.userHash = userHash; - res.userName = uname; - - // somehow they have a block present, but nothing in the user object it specifies - // this shouldn't happen, but let's send feedback if it does - if (!isRegister && isProxyEmpty(rt.proxy)) { - // this really shouldn't happen, but let's handle it anyway - Feedback.send('EMPTY_LOGIN_WITH_BLOCK'); - - //rt.network.disconnect(); // clean up after yourself - waitFor.abort(); - return void cb('NO_SUCH_USER', res); - } - - // they tried to register, but those exact credentials exist - if (isRegister && !isProxyEmpty(rt.proxy)) { - //rt.network.disconnect(); - waitFor.abort(); - res.blockHash = blockHash; - if (shouldImport) { - setMergeAnonDrive(); - } - - return void cb('ALREADY_REGISTERED', res); - } - - if (!isRegister && !isProxyEmpty(rt.proxy)) { - waitFor.abort(); - if (shouldImport) { - setMergeAnonDrive(); - } - var l = Util.find(rt.proxy, ['settings', 'general', 'language']); - var LS_LANG = "CRYPTPAD_LANG"; - if (l) { - localStorage.setItem(LS_LANG, l); - } - - if (res.TOTP_token && res.TOTP_token.bearer) { - LocalStore.setSessionToken(res.TOTP_token.bearer); - } - return void LocalStore.login(undefined, blockHash, uname, function () { - cb(void 0, res); - }); - } - - if (isRegister && isProxyEmpty(rt.proxy)) { - proxy.edPublic = opt.edPublic; - proxy.edPrivate = opt.edPrivate; - proxy.curvePublic = opt.curvePublic; - proxy.curvePrivate = opt.curvePrivate; - proxy.login_name = uname; - proxy[Constants.displayNameKey] = uname; - if (shouldImport) { - setMergeAnonDrive(); - } else { - proxy.version = 11; - } - - Feedback.send('REGISTRATION', true); - } else { - Feedback.send('LOGIN', true); - } - - setTimeout(waitFor(function () { - Realtime.whenRealtimeSyncs(rt.realtime, waitFor()); - })); - })); - }).nThen(function (waitFor) { - require(['/common/pinpad.js'], waitFor(function (_Pinpad) { - console.log("loaded rpc module"); - Pinpad = _Pinpad; - })); - }).nThen(function (waitFor) { - // send an RPC to store the block which you created. - console.log("initializing rpc interface"); - - Pinpad.create(RT.network, Block.keysToRPCFormat(res.opt.blockKeys), waitFor(function (e, _rpc) { - if (e) { - waitFor.abort(); - console.error(e); // INVALID_KEYS - return void cb('RPC_CREATION_ERROR'); - } - rpc = _rpc; - console.log("rpc initialized"); - })); - }).nThen(function (waitFor) { - console.log("creating request to publish a login block"); - - // Finally, create the login block for the object you just created. - var toPublish = {}; - -// XXX I did some basic testing and searching and could not find this attribute -// actually being used anywhere. Including it means either supporting arbitrarily -// large blocks (a DoS vector) or having registration fail for large usernames. -// Can someone please double-check that removing this doesn't break anything? -// --Aaron - //toPublish[Constants.userNameKey] = uname; - toPublish[Constants.userHashKey] = userHash; - toPublish.edPublic = RT.proxy.edPublic; - - Block.writeLoginBlock({ - blockKeys: blockKeys, - content: toPublish - }, waitFor(function (e) { - if (e) { - console.error(e); - waitFor.abort(); - return void cb(e); - } - })); - }).nThen(function (waitFor) { - // confirm that the block was actually written before considering registration successful - Util.fetch(blockUrl, waitFor(function (err /*, block */) { - if (err) { - console.error(err); - waitFor.abort(); - return void cb(err); - } - - console.log("blockInfo available at:", blockHash); - LocalStore.login(undefined, blockHash, uname, function () { - cb(void 0, res); - }); - })); - }); - }; Exports.redirect = function () { if (redirectTo) { var h = redirectTo; @@ -591,6 +89,8 @@ define([ if (hashing) { return void console.log("hashing is already in progress"); } hashing = true; + setMergeAnonDrive(shouldImport); + var proceed = function (result) { hashing = false; // NOTE: test is also use as a cb for the install page @@ -612,7 +112,12 @@ define([ // We need a setTimeout(cb, 0) otherwise the loading screen is only displayed // after hashing the password window.setTimeout(function () { - Exports.loginOrRegister(uname, passwd, isRegister, shouldImport, onOTP, function (err, result) { + Exports.loginOrRegister({ + uname, + passwd, + isRegister, + onOTP + }, function (err, result) { var proxy; if (result) { proxy = result.proxy; } diff --git a/customize.dist/pages/login.js b/customize.dist/pages/login.js index c6774c222..53b569af3 100644 --- a/customize.dist/pages/login.js +++ b/customize.dist/pages/login.js @@ -39,9 +39,8 @@ define([ h('button#register.cp-secondary', Msg.login_register) ), h('button.login', Msg.login_login), - h('br'), - h('button.login', Msg.login_login) - ]) + ]), + Config.sso ? h('div.cp-login-sso') : undefined ]), h('div.col-md-3') ]), diff --git a/customize.dist/pages/ssoauth.js b/customize.dist/pages/ssoauth.js new file mode 100644 index 000000000..691d18470 --- /dev/null +++ b/customize.dist/pages/ssoauth.js @@ -0,0 +1,57 @@ +define([ + '/api/config', + 'jquery', + '/common/hyperscript.js', + '/common/common-interface.js', + '/customize/messages.js', + '/customize/pages.js' +], function (Config, $, h, UI, Msg, Pages) { + + Msg.ssoauth_header = "SSO authentication"; // XXX + Msg.ssoauth_form_hint_register = "Add a CryptPad password for extra security or leave empty and continue"; + Msg.ssoauth_form_hint_login = "Please enter your CryptPad password"; + Msg.continue = "Continue"; + + return function () { + document.title = Msg.ssoauth_header; + + var frame = function (content) { + return [ + h('div#cp-main', [ + Pages.infopageTopbar(), + h('div.container.cp-container', [ + h('div.row.cp-page-title', h('h1', Msg.ssoauth_header)), + ].concat(content)), + Pages.infopageFooter(), + ]), + ]; + }; + + return frame([ + h('div.row', [ + h('div.hidden.col-md-3'), + h('div#userForm.form-group.col-md-6', [ + h('div.cp-ssoauth-pw', [ + h('p.register', Msg.ssoauth_form_hint_register), + h('p.login', Msg.ssoauth_form_hint_login), + h('input.form-control#password', { + type: 'password', + placeholder: Msg.login_password, + }), + h('input.form-control.register#passwordconfirm', { + type: 'password', + placeholder: Msg.login_confirm, + }), + h('div.cp-ssoauth-button', + h('button.btn.btn-primary#cp-ssoauth-button', Msg.continue) + ) + ]), + ]), + h('div.hidden.col-md-3'), + ]) + ]); + }; + +}); + + diff --git a/customize.dist/src/less2/pages/page-ssoauth.less b/customize.dist/src/less2/pages/page-ssoauth.less new file mode 100644 index 000000000..a2c0e0141 --- /dev/null +++ b/customize.dist/src/less2/pages/page-ssoauth.less @@ -0,0 +1,29 @@ +@import (reference) "../include/infopages.less"; +@import (reference) "../include/colortheme-all.less"; +@import (reference) "../include/alertify.less"; +@import (reference) "../include/forms.less"; + +&.cp-page-ssoauth { + .infopages_main(); + .forms_main(); + + .alertify_main(); + + div.cp-ssoauth-pw { + display: none; + } + + &.cp-regsiter { + .login { + display: none; + } + } + &.cp-login { + .register { + display: none; + } + } + +} + + diff --git a/customize.dist/template.js b/customize.dist/template.js index b5eca8b2f..bead8f4a7 100644 --- a/customize.dist/template.js +++ b/customize.dist/template.js @@ -57,6 +57,8 @@ $(function () { require([ '/install/main.js' ], function () {}); } else if (/^\/recovery\//.test(pathname)) { require([ '/recovery/main.js' ], function () {}); + } else if (/^\/ssoauth\//.test(pathname)) { + require([ '/ssoauth/main.js' ], function () {}); } else if (/^\/login\//.test(pathname)) { require([ '/login/main.js' ], function () {}); } else if (/^\/($|^\/index\.html$)/.test(pathname)) { diff --git a/lib/challenge-commands/sso.js b/lib/challenge-commands/sso.js index b00e3a89e..385fa6d70 100644 --- a/lib/challenge-commands/sso.js +++ b/lib/challenge-commands/sso.js @@ -1,28 +1,17 @@ const Util = require("../common-util"); const Commands = module.exports; const SSOUtils = require('../sso-utils'); - -const checkConfig = (Env) => { - return Env && Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) && Env.sso.list.length; -}; -const getProviderConfig = (Env, provider) => { - if (!checkConfig) { return; } - if (!provider) { return; } - const data = Env.sso.list.find((cfg) => { return cfg.name === provider; }); - return data; -}; +const nThen = require("nthen"); +const BlockStore = require("../storage/block"); +const Block = require("../commands/block"); const TYPES = SSOUtils.TYPES; +const checkConfig = SSOUtils.checkConfig; +const getProviderConfig = SSOUtils.getProviderConfig; +const isValidConfig = SSOUtils.isValidConfig; -const isValidConfig = (cfg) => { - if (!cfg) { return; } - if (!cfg.type) { return; } - const type = cfg.type.toLowerCase(); - const idp = TYPES[type]; - if (!idp) { return; } - return idp.checkConfig(cfg); -}; +// Create an SSO authentication request const auth = Commands.SSO_AUTH = function (Env, body, cb) { if (!checkConfig(Env)) { return void cb('INVALID_SERVER_CONFIG'); } const { provider } = body; @@ -58,6 +47,8 @@ auth.complete = function (Env, body, cb, req, res) { }); }; +// Receive authentication data from the IdP. +// Read the auth request, create a JWT and get a block seed for this SSO user. const authCb = Commands.SSO_AUTH_CB = function (Env, body, cb, req) { if (!checkConfig(Env)) { return void cb('INVALID_SERVER_CONFIG'); } const { publicKey } = body; @@ -68,8 +59,6 @@ const authCb = Commands.SSO_AUTH_CB = function (Env, body, cb, req) { if (err) { return void cb('ENOENT'); } const data = Util.tryParse(value); if (!data || !data.type || !TYPES[data.type]) { return void cb('INVALID_REQUEST_DATA'); } - console.log('stored', data.publicKey); - console.log('used', publicKey); if (publicKey !== data.publicKey) { return void cb('WRONG_SIGNATURE_KEYS'); } cb(); }); @@ -86,20 +75,155 @@ authCb.complete = function (Env, body, cb, req) { const data = Util.tryParse(value); const cfg = getProviderConfig(Env, data.provider); const idp = TYPES[data.type]; + const register = data.register; idp.authCb(Env, cfg, ssotoken, url, (err, obj) => { if (err) { return void cb(err); } - const {id, idpData} = obj; - console.log(id, idpData); - SSOUtils.makeUser(Env, id, (err, userData) => { - if (err) { return void cb(err); } + const {id, idpData, name} = obj; - // TODO - // makeTempSession() - - cb(void 0, { state: true }); - }); + let next = (user, isRegister) => { + SSOUtils.createJWT(Env, id, data.provider, idpData, (err, jwt) => { + if (err) { return void cb(err); } + cb(void 0, { + jwt: jwt, + name: name, + seed: user.seed, + password: Boolean(user.password), + register: isRegister + }); + }); + }; + if (register) { + SSOUtils.writeUser(Env, id, (err, userData) => { + if (err && err.code === 'EEXIST') { + return void SSOUtils.readUser(Env, id, (err, userData) => { + if (err) { return void cb(err); } + next(userData, false); + }); + } + if (err) { return void cb(err); } + next(userData, true); + }); + } else { + SSOUtils.readUser(Env, id, (err, userData) => { + if (err) { return void cb(err); } + next(userData, false); + }); + } }); }); }; +// XXX write block change-password? should be the same as otp but without otp code +const register = Commands.SSO_WRITE_BLOCK = function (Env, body, cb) { + const { publicKey, content } = body; + + // they must provide a valid block public key + if (!Block.isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); } + if (publicKey !== content.publicKey) { return void cb("INVALID_KEY"); } + const jwt = content.auth; + if (!jwt) { return void cb('NO_JWT'); } + + BlockStore.isAvailable(Env, publicKey, (err, result) => { + if (err || result) { + return void cb(err || 'EEXISTS'); + } + // No block at this location: continue + cb(); + }); +}; +register.complete = function (Env, body, cb) { + const { publicKey, content } = body; + const jwt = content.auth; + const pw = content.hasPassword; + let payload; + let ssoUser; + // XXX UPDATE sso_user add password boolean + nThen((w) => { + SSOUtils.checkJWT(Env, jwt, w((err, _payload) => { + if (err) { + w.abort(); + return void cb('INVALID_JWT_SIGNATURE'); + } + payload = _payload; + })); + }).nThen((w) => { + const { sub } = payload; + SSOUtils.readUser(Env, sub, w((err, user) => { + if (err) { + w.abort(); + console.log(err, sub); + return void cb('SSO_NO_USER'); + } + ssoUser = user; + })); + }).nThen((w) => { + const { sub } = payload; + SSOUtils.writeBlock(Env, publicKey, sub, w((err) => { + if (err) { + w.abort(); + return void cb('SSO_BLOCK_WRITE'); + } + })); + }).nThen((w) => { + Block.writeLoginBlock(Env, content, w((err) => { + if (err) { w.abort(); } + })); + }).nThen((w) => { + if (!pw) { return; } + const { sub } = payload; + ssoUser.password = true; + SSOUtils.updateUser(Env, sub, ssoUser, w()); + }).nThen(() => { + const { data, provider } = payload; + SSOUtils.makeSession(Env, publicKey, provider, data, cb); + }); +}; + +const login = Commands.SSO_VALIDATE = function (Env, body, cb) { + const { publicKey, jwt } = body; + + // they must provide a valid block public key + if (!Block.isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); } + if (!jwt) { return void cb('NO_JWT'); } + + BlockStore.isAvailable(Env, publicKey, (err, result) => { + if (err && !result) { return void cb(err); } + // Block found + cb(); + }); +}; +login.complete = function (Env, body, cb) { + const { publicKey, jwt } = body; + + let payload; + nThen((w) => { + SSOUtils.checkJWT(Env, jwt, w((err, _payload) => { + if (err) { + w.abort(); + return void cb('INVALID_JWT_SIGNATURE'); + } + payload = _payload; + })); + }).nThen((w) => { + const { sub } = payload; + SSOUtils.readUser(Env, sub, w((err) => { + if (err) { + w.abort(); + console.log(err, sub); + return void cb('SSO_NO_USER'); + } + })); + }).nThen((w) => { + SSOUtils.readBlock(Env, publicKey, w((err) => { + if (err) { + w.abort(); + return void cb('SSO_BLOCK_WRITE'); + } + })); + }).nThen(() => { + const { data, provider } = payload; + SSOUtils.makeSession(Env, publicKey, provider, data, cb); + }); + +}; diff --git a/lib/challenge-commands/totp.js b/lib/challenge-commands/totp.js index 45f2cedd6..ddcfb31a5 100644 --- a/lib/challenge-commands/totp.js +++ b/lib/challenge-commands/totp.js @@ -1,7 +1,6 @@ /* globals Buffer */ const B32 = require("thirty-two"); const OTP = require("notp"); -const JWT = require("jsonwebtoken"); const nThen = require("nthen"); const Util = require("../common-util"); @@ -62,43 +61,10 @@ var decode32 = S => { // Allow user settings? var EXPIRATION = 7 * 24 * 3600 * 1000; // Sessions are valid 7 days -var createJWT = function (Env, sessionId, publicKey, cb) { - JWT.sign({ - // this is a custom JWT field (not a standard) - we include a reference to the session - // which is used to look up whether it has been revoked. - ref: sessionId, - // we specify in the token for what resource the token should be valid (their block's public key) - sub: Util.escapeKeyCharacters(publicKey), - exp: (+new Date()) + EXPIRATION - }, Env.bearerSecret, { - // token integrity is ensured with HMAC SHA512 with the server's bearerSecret - // clients can inspect token parameters, but cannot modify them - algorithm: 'HS512', - // if you want it to expire you can set this for an arbitrary number of seconds in the future, but I won't assume that for now - //expiresIn: (60 * 60 * 24 * 7)), - }, function (err, token) { - if (err) { return void cb(err); } - cb(void 0, token); - }); -}; - // Create a session with a token for the given public key const makeSession = (Env, publicKey, cb) => { const sessionId = Sessions.randomId(); - var token; nThen(function (w) { - /*createJWT(Env, sessionId, publicKey, w(function (err, _token) { - if (err) { - Env.Log.error("TOTP_VALIDATE_JWT_SIGN_ERROR", { - error: Util.serializeError(err), - publicKey: publicKey, - }); - w.abort(); - return void cb("TOKEN_ERROR"); - } - token = _token; - }));*/ - }).nThen(function (w) { // store the token Sessions.write(Env, publicKey, sessionId, JSON.stringify({ mfa: { @@ -340,7 +306,7 @@ So, we should: }; // Same as TOTP_VALIDATE but without making a session at the end -const check = Commands.TOTP_CHECK = function (Env, body, cb) { +const check = Commands.TOTP_MFA_CHECK = function (Env, body, cb) { var { publicKey, auth } = body; const code = auth; if (!isValidOTP(code)) { return void cb('E_INVALID'); } diff --git a/lib/http-commands.js b/lib/http-commands.js index 2c19de250..8e397b5b1 100644 --- a/lib/http-commands.js +++ b/lib/http-commands.js @@ -68,7 +68,7 @@ COMMANDS.REMOVE_BLOCK = NOAUTH.REMOVE_BLOCK; const TOTP = require("./challenge-commands/totp.js"); COMMANDS.TOTP_SETUP = TOTP.TOTP_SETUP; COMMANDS.TOTP_VALIDATE = TOTP.TOTP_VALIDATE; -COMMANDS.TOTP_CHECK = TOTP.TOTP_CHECK; +COMMANDS.TOTP_MFA_CHECK = TOTP.TOTP_MFA_CHECK; COMMANDS.TOTP_REVOKE = TOTP.TOTP_REVOKE; COMMANDS.TOTP_WRITE_BLOCK = TOTP.TOTP_WRITE_BLOCK; COMMANDS.TOTP_REMOVE_BLOCK = TOTP.TOTP_REMOVE_BLOCK; @@ -76,6 +76,8 @@ COMMANDS.TOTP_REMOVE_BLOCK = TOTP.TOTP_REMOVE_BLOCK; const SSO = require("./challenge-commands/sso.js"); COMMANDS.SSO_AUTH = SSO.SSO_AUTH; COMMANDS.SSO_AUTH_CB = SSO.SSO_AUTH_CB; +COMMANDS.SSO_WRITE_BLOCK = SSO.SSO_WRITE_BLOCK; +COMMANDS.SSO_VALIDATE = SSO.SSO_VALIDATE; var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-'); diff --git a/lib/http-worker.js b/lib/http-worker.js index 855062c59..50fba51c0 100644 --- a/lib/http-worker.js +++ b/lib/http-worker.js @@ -7,8 +7,8 @@ const nThen = require("nthen"); const Util = require("./common-util"); const Logger = require("./log"); const AuthCommands = require("./http-commands"); -const JWT = require("jsonwebtoken"); const MFA = require("./storage/mfa"); +const SSOUtils = require("./sso-utils"); const Sessions = require("./storage/sessions"); const cookieParser = require("cookie-parser"); @@ -272,7 +272,7 @@ app.use('/block/', function (req, res, next) { var authorization = req.headers.authorization; - var mfa_params, jwt_payload; + var mfa_params, sso_params; nThen(function (w) { // First, check whether the block id in question has any MFA settings stored MFA.read(Env, name, w(function (err, content) { @@ -281,8 +281,7 @@ app.use('/block/', function (req, res, next) { // in either case you can abort and fall through // allowing the static webserver to handle either case if (err && err.code === 'ENOENT') { - w.abort(); - return void next(); + return; } // we're not expecting other errors. the sensible thing is to fail @@ -313,10 +312,30 @@ app.use('/block/', function (req, res, next) { }); } })); + + // Same for SSO settings + SSOUtils.readBlock(Env, name, w(function (err, content) { + if (err && err.code === 'ENOENT') { + return; + } + if (err) { + Log.error('GET_BLOCK_METADATA', err); + return void res.status(500).json({ + code: 500, + error: "UNEXPECTED_ERROR", + }); + } + sso_params = content; + })); + }).nThen(function (w) { + if (!mfa_params && !sso_params) { + w.abort(); + next(); + } }).nThen(function (w) { // We should only be able to reach this logic // if we successfully loaded and parsed some JSON - // representing the user's MFA settings. + // representing the user's MFA and/or SSO settings. // Failures at this point relate to insufficient or incorrect authorization. // This function standardizes how we reject such requests. @@ -328,18 +347,18 @@ app.use('/block/', function (req, res, next) { var no = function () { w.abort(); res.status(401).json({ - method: mfa_params.method, + method: (mfa_params && mfa_params.method) || (sso_params && 'SSO'), code: 401 }); }; - // if you are here it is because this block is protected by MFA. + // if you are here it is because this block is protected by MFA or SSO. // they will need to provide a JSON Web Token, so we can reject them outright // if one is not present in their authorization header if (!authorization) { return void no(); } // The authorization header should be of the form - // "Authorization: Bearer " + // "Authorization: Bearer " // We can reject the request if it is malformed. let token = authorization.replace(/^Bearer\s+/, '').trim(); if (!token) { return void no(); } @@ -355,8 +374,12 @@ app.use('/block/', function (req, res, next) { let content = Util.tryParse(contentStr); + if (mfa_params && !content.mfa) { return void no(); } + if (sso_params && !content.sso) { return void no(); } + if (content.mfa && content.mfa.exp && ((+new Date()) > content.mfa.exp)) { - Log.error("OTP_SESSION_EXPIRED", payload); + Log.error("OTP_SESSION_EXPIRED", content.mfa); + // XXX Only delete the mfa part Sessions.delete(Env, name, token, function (err) { if (err) { Log.error('SESSION_DELETE_EXPIRED_ERROR', err); @@ -367,93 +390,34 @@ app.use('/block/', function (req, res, next) { return void no(); } - // we could also check whether the content of the file matches the token, - // but clients don't have any influence over the reference and can only - // request to create tokens that are scoped to a public key they control. - // I don' think there's any practical benefit to such a check. - // So, interpret the existence of a file in that location as the continued - // validity of the session. Fall through and let the built-in webserver - // handle the 404 or serving the file. - next(); - }); - - // Otherwise we attempt to validate the token - // Successful validation implies that the token was issued by the server - // since only the server should possess the current bearer secret (unless it has leaked). - - // It is still possible that the token is not valid for this particular resource, - // so the algorithm (HMAC SHA512) only asserts its integrity, not its validity. - /* - JWT.verify(token, Env.bearerSecret, { - algorithm: 'HS512', - }, w(function (err, payload) { - if (err) { - // the token could not be validated for some reason. - // it might have expired, the server might have rotated secrets, - // it might not be well-formed, etc. - // log and respond. - Log.info('INVALID_JWT', { - error: err, - token: token, - }); - return void no(); - } - - // Now that we have the payload we can inspect its properties - // and reject anything which is obviously wrong without requiring - // any async I/O - - // Tokens are issued with a "reference" - a random id which is - // used alongside the block id to look up whether a given session - // is still valid - - // reject if it does not provide a lookup reference - if (typeof(payload.ref) !== 'string') { - Log.error("JWT_NO_REFERENCE", payload); - return void no(); - } - - // A JWT can optionally indicate a finite lifetime. - // reject if it's too old - if (payload.exp && ((+new Date()) > payload.exp)) { - Log.error("JWT_EXPIRED", payload); - Sessions.delete(Env, name, payload.ref, function (err) { - if (err) { - Log.error('JWT_SESSION_DELETE_EXPIRED_ERROR', err); - return; + if (content.sso) { + SSOUtils.checkSession(Env, content.sso, (err, state, newState) => { + if (err || !state) { + // XXX Only delete the mfa part + Sessions.delete(Env, name, token, function (err) { + if (err) { + Log.error('SSO_SESSION_DELETE_EXPIRED_ERROR', err); + return; + } + Log.info('SSO_SESSION_DELETE_EXPIRED', err); + }); + return void no(); } - Log.info('JWT_SESSION_DELETE_EXPIRED', err); - }); - return void no(); - } - - // A JWT indicates the subject (the block id) for which it is valid - // reject if it does not match the block the client is trying to access - if (payload.sub !== name) { - Log.error("JWT_SUBJECT_MISMATCH", payload); - return void no(); - } - - // otherwise, it seems basically correct. - Log.verbose("VALID_JWT", payload); - - // remember the payload for subsequent asynchronous checks - jwt_payload = payload; - })); - */ - }).nThen(function () { - // Finally, even if the JWT itself seems valid, the database - // is the final authority as to whether the session is still valid, - // as it might have been revoked - /* - Sessions.read(Env, name, jwt_payload.ref, function (err) { - if (err) { - Log.error('JWT_SESSION_READ_ERROR', err); - return res.status(401).json({ - method: mfa_params.method, - code: 401, + if (newState) { + content.sso = newState; + // XXX We should produce a new session ID here and send it back to the user + Sessions.update(Env, name, token, JSON.stringify(content), (err) => { + if (err) { + Log.error('SSO_SESSION_UPDATE_ERROR', err); + return; + } + Log.info('SSO_SESSION_UPDATED', err); + }); + } + next(); }); + return; } // we could also check whether the content of the file matches the token, @@ -466,7 +430,6 @@ app.use('/block/', function (req, res, next) { // handle the 404 or serving the file. next(); }); - */ }); }); diff --git a/lib/plugins/sso/oidc.js b/lib/plugins/sso/oidc.js index a61ce0846..caa904a85 100644 --- a/lib/plugins/sso/oidc.js +++ b/lib/plugins/sso/oidc.js @@ -27,10 +27,6 @@ module.exports = { const generators = OID.generators; const code_verifier = generators.codeVerifier(); - // store the code_verifier in your framework's session mechanism, if it is a cookie based solution - // it should be httpOnly (not readable by javascript) and encrypted. - - console.log(code_verifier); const code_challenge = generators.codeChallenge(code_verifier); const url = client.authorizationUrl({ scope: 'openid email profile',// https://www.googleapis.com/auth/contacts.readonly', // https://www.google.com/m8/feeds @@ -52,10 +48,11 @@ module.exports = { .then((tokenSet) => { let j = tokenSet; let c = tokenSet.claims(); - console.log(j, c); cb(void 0, { id: c.sub, + name: c.name, idpData: { + expires_at: j.expires_at, access_token: j.access_token, refresh_token: j.refresh_token, id_token: j.id_token @@ -63,5 +60,25 @@ module.exports = { }); }); }); + }, + checkSession: (Env, cfg, data, cb) => { + const { refresh_token } = data; + + let t = new OID.TokenSet(data); + if (!t.expired()) { return void cb(void 0, true); } + getClient(cfg, (err, client) => { + client.refresh(t.refresh_token).then((j) => { + const newData = { + refresh_token: refresh_token, + id_token: j.id_token, + access_token: j.access_token, + expires_at: j.expires_at + }; + cb(void 0, true, newData); + }, () => { + // Error: can't renew token + cb(void 0, false); + }); + }); } }; diff --git a/lib/sso-utils.js b/lib/sso-utils.js index 1bad43d5d..3e11047e2 100644 --- a/lib/sso-utils.js +++ b/lib/sso-utils.js @@ -1,13 +1,39 @@ const SSO = require("./storage/sso"); +const Sessions = require("./storage/sessions"); const Nacl = require("tweetnacl/nacl-fast"); +const JWT = require("jsonwebtoken"); +const Util = require("./common-util"); const SSOUtils = module.exports; // XXX const SAML = require('node-saml'); // https://www.npmjs.com/package/node-saml -SSOUtils.TYPES = { +const TYPES = SSOUtils.TYPES = { oidc: require('./plugins/sso/oidc') }; +const checkConfig = SSOUtils.checkConfig = (Env) => { + return Env && Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) && Env.sso.list.length; +}; +const getProviderConfig = SSOUtils.getProviderConfig = (Env, provider) => { + if (!checkConfig(Env)) { return; } + if (!provider) { return; } + const data = Env.sso.list.find((cfg) => { return cfg.name === provider; }); + return data; +}; +SSOUtils.isValidConfig = (cfg) => { + if (!cfg) { return; } + if (!cfg.type) { return; } + const type = cfg.type.toLowerCase(); + const idp = TYPES[type]; + if (!idp) { return; } + return idp.checkConfig(cfg); +}; + +SSOUtils.checkSession = (Env, sessionData, cb) => { + const cfg = getProviderConfig(Env, sessionData.provider); + const idp = TYPES[cfg.type.toLowerCase()]; + idp.checkSession(Env, cfg, sessionData.data, cb); +}; SSOUtils.deleteRequest = (Env, id) => { SSO.request.delete(Env, id, (err) => { @@ -34,15 +60,84 @@ SSOUtils.writeRequest = (Env, data, cb) => { }; -SSOUtils.makeUser = (Env, id, cb) => { - const seed = Nacl.util.encodeBase64(Nacl.util.randomBytes(24)); - SSO.User.write(Env, id, JSON.stringify({ +SSOUtils.writeUser = (Env, id, cb) => { + const seed = Nacl.util.encodeBase64(Nacl.randomBytes(24)); + SSO.user.write(Env, id, JSON.stringify({ seed: seed, - password: false // XXX maybe we don't need that flag, we can check if the block exists later + password: false }), (err) => { if (err) { return void cb(err); } cb(void 0, { seed }); }); +}; +SSOUtils.readUser = (Env, id, cb) => { + SSO.user.read(Env, id, (err, user) => { + if (err) { return void cb(err); } + cb(void 0, Util.tryParse(user)); + }); +}; +SSOUtils.updateUser = (Env, id, data, cb) => { + SSO.user.delete(Env, id, () => { + SSO.user.write(Env, id, JSON.stringify(data), (err) => { + if (err) { return void cb(err); } + cb(); + }); + }); +}; + +SSOUtils.writeBlock = (Env, id, ssoID, cb) => { + SSO.block.write(Env, id, JSON.stringify({ + id: ssoID + }), (err) => { + if (err) { return void cb(err); } + cb(); + }); +}; +SSOUtils.readBlock = (Env, id, cb) => { + SSO.block.read(Env, id, (err, blockData) => { + if (err) { return void cb(err); } + cb(void 0, Util.tryParse(blockData)); + }); +}; + +// Store the SSO data (tokens, etc.) in a JWT while waiting for the user's CryptPad password +SSOUtils.createJWT = (Env, ssoId, provider, data, cb) => { + JWT.sign({ + sub: ssoId, + data: data, + provider: provider + }, Env.bearerSecret, { + // token integrity is ensured with HMAC SHA512 with the server's bearerSecret + // clients can inspect token parameters, but cannot modify them + algorithm: 'HS512', + // if you want it to expire you can set this for an arbitrary number of seconds in the future + expiresIn: 300, + }, function (err, token) { + if (err) { return void cb(err); } + cb(void 0, token); + }); +}; +SSOUtils.checkJWT = (Env, token, cb) => { + JWT.verify(token, Env.bearerSecret, { + algorithm: 'HS512', + }, function (err, payload) { + if (err) { + // the token could not be validated for some reason. + // it might have expired, the server might have rotated secrets, + // it might not be well-formed, etc. + // log and respond. + Env.Log.info('INVALID_JWT', { + error: err, + token: token, + }); + return void cb('INVALID_JWT'); + } + + // otherwise, it seems basically correct. + Env.Log.verbose("VALID_JWT", payload); + + cb(void 0, payload); + }); }; @@ -61,7 +156,7 @@ SSOUtils.makeSession = (Env, publicKey, provider, ssoData, cb) => { publicKey: publicKey, sessionId: sessionId, }); - return void cb("SESSION_WRITE_ERROR"); + return void cb("SSO_NO_SESSION"); } cb(void 0, { bearer: sessionId, diff --git a/lib/storage/sessions.js b/lib/storage/sessions.js index f31151ec8..30283dc47 100644 --- a/lib/storage/sessions.js +++ b/lib/storage/sessions.js @@ -42,6 +42,14 @@ Sessions.delete = function (Env, id, ref, cb) { Basic.delete(Env, path, cb); }; +Sessions.update = function (Env, id, ref, data, cb) { + var path = pathFromId(Env, id, ref); + Basic.delete(Env, path, (err) => { + if (err) { return void cb(err); } + Basic.write(Env, path, data, cb); + }); +}; + Sessions.deleteUser = function (Env, id, cb) { if (!id || typeof(id) !== 'string') { return; } id = Util.escapeKeyCharacters(id); diff --git a/lib/storage/sso.js b/lib/storage/sso.js index 65110ee30..b28203fd5 100644 --- a/lib/storage/sso.js +++ b/lib/storage/sso.js @@ -6,7 +6,8 @@ const SSO = module.exports; /* This module manages storage related to Single Sign-On (SSO) settings. A first part (sso-requests) contains temporary files for sso authentication with a remote service -and a second part (sso users) is a database of accounts registered via SSO. +A second part (sso-users) is a database of accounts registered via SSO (SSO id ==> block seed) +A third part (sso-blocks) is a databse of blocks that are sso-protected (block id ==> SSO id...) The path for requests is based on the "authentication request" token depending on the type of SSO. The path for the user database is based on their persistent identifier (id) from the SSO. @@ -21,7 +22,10 @@ var reqPathFromId = function (Env, id) { return pathFromId(Env, id, 'sso_request'); }; var userPathFromId = function (Env, id) { - return pathFromId(Env, id, 'sso'); + return pathFromId(Env, id, 'sso_user'); +}; +var blockPathFromId = function (Env, id) { + return pathFromId(Env, id, 'sso_block'); }; const Req = SSO.request = {}; @@ -55,3 +59,18 @@ User.delete = function (Env, id, cb) { var path = userPathFromId(Env, id); Basic.delete(Env, path, cb); }; + +const Block = SSO.block = {}; + +Block.read = function (Env, id, cb) { + var path = blockPathFromId(Env, id); + Basic.read(Env, path, cb); +}; +Block.write = function (Env, id, data, cb) { + var path = blockPathFromId(Env, id); + Basic.write(Env, path, data, cb); +}; +Block.delete = function (Env, id, cb) { + var path = blockPathFromId(Env, id); + Basic.delete(Env, path, cb); +}; diff --git a/www/common/common-login.js b/www/common/common-login.js new file mode 100644 index 000000000..007c0bbfc --- /dev/null +++ b/www/common/common-login.js @@ -0,0 +1,520 @@ +define([ + 'chainpad-listmap', + '/bower_components/chainpad-crypto/crypto.js', + '/common/common-util.js', + '/common/outer/network-config.js', + '/common/common-credential.js', + '/bower_components/chainpad/chainpad.dist.js', + '/common/common-realtime.js', + '/common/common-constants.js', + '/common/common-interface.js', + '/common/common-feedback.js', + '/common/outer/local-store.js', + '/customize/messages.js', + '/bower_components/nthen/index.js', + '/common/outer/login-block.js', + '/common/common-hash.js', + '/common/outer/http-command.js', + + '/bower_components/tweetnacl/nacl-fast.min.js', + '/bower_components/scrypt-async/scrypt-async.min.js', // better load speed +], function (Listmap, Crypto, Util, NetConfig, Cred, ChainPad, Realtime, Constants, UI, + Feedback, LocalStore, Messages, nThen, Block, Hash, ServerCommand) { + var Nacl = window.nacl; + + var Exports = { + requiredBytes: 192, + }; + + var allocateBytes = Exports.allocateBytes = function (bytes) { + var dispense = Cred.dispenser(bytes); + + var opt = {}; + + // dispense 18 bytes of entropy for your encryption key + var encryptionSeed = dispense(18); + // 16 bytes for a deterministic channel key + var channelSeed = dispense(16); + // 32 bytes for a curve key + var curveSeed = dispense(32); + + var curvePair = Nacl.box.keyPair.fromSecretKey(new Uint8Array(curveSeed)); + opt.curvePrivate = Nacl.util.encodeBase64(curvePair.secretKey); + opt.curvePublic = Nacl.util.encodeBase64(curvePair.publicKey); + + // 32 more for a signing key + var edSeed = opt.edSeed = dispense(32); + + // 64 more bytes to seed an additional signing key + var blockKeys = opt.blockKeys = Block.genkeys(new Uint8Array(dispense(64))); + opt.blockHash = Block.getBlockHash(blockKeys); + + // derive a private key from the ed seed + var signingKeypair = Nacl.sign.keyPair.fromSeed(new Uint8Array(edSeed)); + + opt.edPrivate = Nacl.util.encodeBase64(signingKeypair.secretKey); + opt.edPublic = Nacl.util.encodeBase64(signingKeypair.publicKey); + + var keys = opt.keys = Crypto.createEditCryptor(null, encryptionSeed); + + // 24 bytes of base64 + keys.editKeyStr = keys.editKeyStr.replace(/\//g, '-'); + + // 32 bytes of hex + var channelHex = opt.channelHex = Util.uint8ArrayToHex(channelSeed); + + // should never happen + if (channelHex.length !== 32) { throw new Error('invalid channel id'); } + + var channel64 = Util.hexToBase64(channelHex); + + // we still generate a v1 hash because this function needs to deterministically + // derive the same values as it always has. New accounts will generate their own + // userHash values + opt.userHash = '/1/edit/' + [channel64, opt.keys.editKeyStr].join('/') + '/'; + + return opt; + }; + + var loginOptionsFromBlock = Exports.loginOptionsFromBlock = function (blockInfo) { + var opt = {}; + var parsed = Hash.getSecrets('pad', blockInfo.User_hash); + opt.channelHex = parsed.channel; + opt.keys = parsed.keys; + opt.edPublic = blockInfo.edPublic; + return opt; + }; + + var loadUserObject = Exports.loadUserObject = function (opt, cb) { + var config = { + websocketURL: NetConfig.getWebsocketURL(), + channel: opt.channelHex, + data: {}, + validateKey: opt.keys.validateKey, // derived validation key + crypto: Crypto.createEncryptor(opt.keys), + logLevel: 1, + classic: true, + ChainPad: ChainPad, + owners: [opt.edPublic] + }; + + var rt = opt.rt = Listmap.create(config); + rt.proxy + .on('ready', function () { + setTimeout(function () { cb(void 0, rt); }); + }) + .on('disconnect', function (info) { + cb('E_DISCONNECT', info); + }); + }; + + var isProxyEmpty = Exports.isProxyEmpty = function (proxy) { + var l = Object.keys(proxy).length; + return l === 0 || (l === 2 && proxy._events && proxy.on); + }; + + var legacyLogin = function (opt, isRegister, cb, res) { + res = res || {}; + loadUserObject(opt, function (err, rt) { + if (err) { return void cb(err); } + + // if a proxy is marked as deprecated, it is because someone had a non-owned drive + // but changed their password, and couldn't delete their old data. + // if they are here, they have entered their old credentials, so we should not + // allow them to proceed. In time, their old drive should get deleted, since + // it will should be pinned by anyone's drive. + if (rt.proxy[Constants.deprecatedKey]) { + return void cb('NO_SUCH_USER'); + } + + if (isRegister && isProxyEmpty(rt.proxy)) { + // If they are trying to register, + // and the proxy is empty, then there is no 'legacy user' either + // so we should just shut down this session and disconnect. + //rt.network.disconnect(); + return void cb(); // proceed to the next async block + } + + // they tried to just log in but there's no such user + // and since we're here at all there is no modern-block + if (!isRegister && isProxyEmpty(rt.proxy)) { + return void cb('NO_SUCH_USER'); + } + + // they tried to register, but those exact credentials exist + if (isRegister && !isProxyEmpty(rt.proxy)) { + Feedback.send('LOGIN', true); + return void cb('ALREADY_REGISTERED'); + } + + // if you are here, then there is no block, the user is trying + // to log in. The proxy is **not** empty. All values assigned here + // should have been deterministically created using their credentials + // so setting them is just a precaution to keep things in good shape + res.proxy = rt.proxy; + res.realtime = rt.realtime; + res.network = rt.network; + + // they're registering... + res.userHash = opt.userHash; + res.userName = res.uname; + + // export their signing key + res.edPrivate = opt.edPrivate; + res.edPublic = opt.edPublic; + + // export their encryption key + res.curvePrivate = opt.curvePrivate; + res.curvePublic = opt.curvePublic; + + // don't proceed past this async block. + + // We have to call whenRealtimeSyncs asynchronously here because in the current + // version of listmap, onLocal calls `chainpad.contentUpdate(newValue)` + // asynchronously. + // The following setTimeout is here to make sure whenRealtimeSyncs is called after + // `contentUpdate` so that we have an update userDoc in chainpad. + setTimeout(function () { + Realtime.whenRealtimeSyncs(rt.realtime, function () { + // the following stages are there to initialize a new drive + // if you are registering + LocalStore.login(res.userHash, undefined, res.userName, function () { + setTimeout(function () { cb(void 0, res); }); + }); + }); + }); + }); + }; + + var getProxyOpt = function (blockInfo) { + var opt; + if (blockInfo) { + opt = loginOptionsFromBlock(blockInfo); + opt.userHash = blockInfo.User_hash; + } else { + console.log("allocating random bytes for a new user object"); + opt = allocateBytes(Nacl.randomBytes(Exports.requiredBytes)); + // create a random v2 hash, since we don't need backwards compatibility + opt.userHash = Hash.createRandomHash('drive'); + var secret = Hash.getSecrets('drive', opt.userHash); + opt.keys = secret.keys; + opt.channelHex = secret.channel; + } + + console.warn(opt); + return opt; + }; + + var modernLoginRegister = function (opt, isRegister, cb, res) { + res = res || {}; + // according to the location derived from the credentials which you entered + loadUserObject(opt, function (err, rt) { + if (err) { return void cb('MODERN_REGISTRATION_INIT'); } + + // export the realtime object you checked + var RT = rt; + + var proxy = rt.proxy; + if (isRegister && !isProxyEmpty(proxy) && (!proxy.edPublic || !proxy.edPrivate)) { + console.error("INVALID KEYS"); + console.log(JSON.stringify(proxy)); + return void cb(void 0, void 0, RT); + } + + res.proxy = rt.proxy; + res.realtime = rt.realtime; + res.network = rt.network; + + // they're registering... + res.userHash = opt.userHash; + res.userName = res.uname; + + // somehow they have a block present, but nothing in the user object it specifies + // this shouldn't happen, but let's send feedback if it does + if (!isRegister && isProxyEmpty(rt.proxy)) { + // this really shouldn't happen, but let's handle it anyway + Feedback.send('EMPTY_LOGIN_WITH_BLOCK'); + return void cb('NO_SUCH_USER'); + } + + // they tried to register, but those exact credentials exist + if (isRegister && !isProxyEmpty(rt.proxy)) { + //rt.network.disconnect(); + return void cb('ALREADY_REGISTERED'); + } + + if (!isRegister && !isProxyEmpty(rt.proxy)) { + var l = Util.find(rt.proxy, ['settings', 'general', 'language']); + var LS_LANG = "CRYPTPAD_LANG"; + if (l) { localStorage.setItem(LS_LANG, l); } + + if (res.auth_token && res.auth_token.bearer) { + LocalStore.setSessionToken(res.auth_token.bearer); + } + return void LocalStore.login(undefined, res.blockHash, res.uname, function () { + cb(void 0, res, RT); + }); + } + + if (isRegister && isProxyEmpty(rt.proxy)) { + proxy.edPublic = opt.edPublic; + proxy.edPrivate = opt.edPrivate; + proxy.curvePublic = opt.curvePublic; + proxy.curvePrivate = opt.curvePrivate; + proxy.login_name = res.uname; + proxy[Constants.displayNameKey] = res.uname; + proxy.version = 11; + + Feedback.send('REGISTRATION', true); + } else { + Feedback.send('LOGIN', true); + } + + setTimeout(function () { + Realtime.whenRealtimeSyncs(rt.realtime, function () { + cb(void 0, void 0, RT); + }); + }); + }); + }; + + Exports.loginOrRegister = function (config, cb) { + let { uname, passwd, isRegister, onOTP, ssoAuth } = config; + if (typeof(cb) !== 'function') { return; } + + // Usernames are all lowercase. No going back on this one + uname = uname.toLowerCase(); + + // validate inputs + if (!Cred.isValidUsername(uname)) { return void cb('INVAL_USER'); } + if (!Cred.isValidPassword(passwd) && !ssoAuth) { return void cb('INVAL_PASS'); } + if (isRegister && !ssoAuth && !Cred.isLongEnoughPassword(passwd)) { + return void cb('PASS_TOO_SHORT'); + } + + // results... + var res = { + register: isRegister, + uname: uname + }; + if (ssoAuth && ssoAuth.name) { res.uname = ssoAuth.name; } + + var RT, blockKeys, blockUrl; + + nThen(function (waitFor) { + // derive a predefined number of bytes from the user's inputs, + // and allocate them in a deterministic fashion + Cred.deriveFromPassphrase(uname, passwd, Exports.requiredBytes, waitFor(function (bytes) { + res.opt = allocateBytes(bytes); + res.blockHash = res.opt.blockHash; + blockKeys = res.opt.blockKeys; + })); + }).nThen(function (waitFor) { + // the allocated bytes can be used either in a legacy fashion, + // or in such a way that a previously unused byte range determines + // the location of a layer of indirection which points users to + // an encrypted block, from which they can recover the location of + // the rest of their data + + // determine where a block for your set of keys would be stored + blockUrl = Block.getBlockUrl(res.opt.blockKeys); + + var TOTP_prompt = function (err, cb) { + onOTP(function (code) { + ServerCommand(res.opt.blockKeys.sign, { + command: 'TOTP_VALIDATE', + code: code, + // TODO optionally allow the user to specify a lifetime for this session? + // this will require a little bit of server work + // and more UI/UX: + // ie. just a simple "remember me" checkbox? + // allow them to specify a lifetime for the session? + // "log me out after one day"? + }, cb); + }, false, err); + }; + + var done = waitFor(); + var responseToDecryptedBlock = function (response, cb) { + response.arrayBuffer().then(arraybuffer => { + arraybuffer = new Uint8Array(arraybuffer); + var decryptedBlock = Block.decrypt(arraybuffer, blockKeys); + if (!decryptedBlock) { + console.error("BLOCK DECRYPTION ERROR"); + return void cb("BLOCK_DECRYPTION_ERROR"); + } + cb(void 0, decryptedBlock); + }); + }; + + var missingAuth; + nThen(function (w) { + Util.getBlock(blockUrl, { + // request the block without credentials + }, w(function (err, response) { + if (err === 401) { + missingAuth = response && response.method; + return void console.log("Block requires 2FA"); + } + + // Some other error? + if (err) { + console.error(err); + w.abort(); + return void done(); + } + + // If the block was returned without requiring authentication + // then we can abort the subsequent steps of this nested nThen + w.abort(); + + // decrypt the response and continue the normal procedure with its payload + responseToDecryptedBlock(response, function (err, decryptedBlock) { + if (err) { + // if a block was present but you were not able to decrypt it... + console.error(err); + waitFor.abort(); + return void cb(err); + } + res.blockInfo = decryptedBlock; + done(); + }); + })); + }).nThen(function (w) { + if (missingAuth !== 'SSO') { return; } // XXX multiple auth + ServerCommand(res.opt.blockKeys.sign, { + command: 'SSO_VALIDATE', + jwt: ssoAuth.data, + }, w(function (err, response) { + if (err) { + // XXX + return; + } + res.auth_token = response; + })); + }).nThen(function (w) { + if (missingAuth !== 'TOTP') { return; } // XXX multiple auth + // if you're here then you need to request a JWT + var done = w(); + var tries = 3; + var ask = function () { + if (!tries) { + w.abort(); + waitFor.abort(); + return void cb('TOTP_ATTEMPTS_EXHAUSTED'); + } + tries--; + TOTP_prompt(tries !== 2, function (err, response) { + // ask again until your number of tries are exhausted + if (err) { + console.error(err); + console.log("Normal failure. Asking again..."); + return void ask(); + } + if (!response || !response.bearer) { + console.log(response); + console.log("Unexpected failure. No bearer token. Asking again"); + return void ask(); + } + console.log("Successfully retrieved a bearer token"); + res.auth_token = response; + done(); + }); + }; + ask(); + }).nThen(function (w) { + Util.getBlock(blockUrl, res.auth_token, function (err, response) { + if (err) { + w.abort(); + console.error(err); + return void cb('BLOCK_ERROR_3'); + } + + responseToDecryptedBlock(response, function (err, decryptedBlock) { + if (err) { + waitFor.abort(); + return void cb(err); + } + res.blockInfo = decryptedBlock; + done(); + }); + }); + }); + }).nThen(function (waitFor) { + + // we assume that if there is a block, it was created in a valid manner + // so, just proceed to the next block which handles that stuff + if (res.blockInfo) { return; } + + var opt = res.opt; + + // load the user's object using the legacy credentials + legacyLogin(opt, isRegister, waitFor(function (err, data) { + if (err) { + waitFor.abort(); + return void cb(err); + } + if (!data) { return; } // Go to next block (modern registration) + + // No error and data: success legacy login + waitFor.abort(); + cb(void 0, data); + }), res); + }).nThen(function (waitFor) { // MODERN REGISTRATION / LOGIN + var opt = getProxyOpt(res.blockInfo); + + modernLoginRegister(opt, isRegister, waitFor(function (err, data, _RT) { + if (err) { + waitFor.abort(); + return void cb(err); + } + RT = _RT; + if (!data) { return; } // Go to next block (modern registration) + + // No error and data: success modern login + waitFor.abort(); + cb(void 0, data); + }), res); + }).nThen(function (waitFor) { + console.log("creating request to publish a login block"); + + // Finally, create the login block for the object you just created. + var toPublish = {}; + toPublish[Constants.userHashKey] = res.userHash; + toPublish.edPublic = RT.proxy.edPublic; + + Block.writeLoginBlock({ + pw: Boolean(passwd), + auth: ssoAuth, + blockKeys: blockKeys, + content: toPublish + }, waitFor(function (e, res) { + if (e === 'SSO_NO_SESSION') { return; } // account created, need re-login + if (e) { + console.error(e); + waitFor.abort(); + return void cb(e); + } + if (res && res.bearer) { + LocalStore.setSessionToken(res.bearer); + } + })); + }).nThen(function (waitFor) { + // confirm that the block was actually written before considering registration successful + Util.getBlock(blockUrl, {}, waitFor(function (err /*, block */) { + if (err && err !== 401) { // 401 is fine + console.error(err); + waitFor.abort(); + return void cb(err); + } + + console.log("blockInfo available at:", res.blockHash); + LocalStore.login(undefined, res.blockHash, uname, function () { + cb(void 0, res); + }); + })); + }); + }; + + return Exports; +}); diff --git a/www/common/outer/login-block.js b/www/common/outer/login-block.js index 67eaaa128..ccddfb780 100644 --- a/www/common/outer/login-block.js +++ b/www/common/outer/login-block.js @@ -165,9 +165,7 @@ define([ const { blockKeys, auth } = data; var command = 'MFA_CHECK'; - if (auth && auth.type === 'TOTP') { - command = 'TOTP_CHECK'; - } + if (auth && auth.type) { command = `${auth.type.toUpperCase()}_` + command; } ServerCommand(blockKeys.sign, { command: command, @@ -175,15 +173,14 @@ define([ }, cb); }; Block.writeLoginBlock = function (data, cb) { - const { content, blockKeys, oldBlockKeys, auth } = data; + const { content, blockKeys, oldBlockKeys, auth, pw } = data; var command = 'WRITE_BLOCK'; - if (auth && auth.type === 'TOTP') { - command = 'TOTP_WRITE_BLOCK'; - } + if (auth && auth.type) { command = `${auth.type.toUpperCase()}_` + command; } var block = Block.serialize(JSON.stringify(content), blockKeys); block.auth = auth && auth.data; + block.hasPassword = pw; block.registrationProof = oldBlockKeys && Block.proveAncestor(oldBlockKeys); ServerCommand(blockKeys.sign, { @@ -195,9 +192,7 @@ define([ const { blockKeys, auth } = data; var command = 'REMOVE_BLOCK'; - if (auth && auth.type === 'TOTP') { - command = 'TOTP_REMOVE_BLOCK'; - } + if (auth && auth.type) { command = `${auth.type.toUpperCase()}_` + command; } ServerCommand(blockKeys.sign, { command: command, diff --git a/www/install/main.js b/www/install/main.js index e7c03e3cd..a8352e588 100644 --- a/www/install/main.js +++ b/www/install/main.js @@ -126,7 +126,8 @@ define([ function (yes) { if (!yes) { return; } - Login.loginOrRegisterUI(uname, passwd, true, shouldImport, false, function (data) { + Login.loginOrRegisterUI(uname, passwd, true, shouldImport, + UI.getOTPScreen, false, function (data) { var proxy = data.proxy; if (!proxy || !proxy.edPublic) { UI.alert(Messages.error); return true; } diff --git a/www/login/main.js b/www/login/main.js index edbc38f16..ad3483533 100644 --- a/www/login/main.js +++ b/www/login/main.js @@ -1,5 +1,7 @@ define([ + '/api/config', 'jquery', + '/common/hyperscript.js', '/common/cryptpad-common.js', '/customize/login.js', '/common/common-interface.js', @@ -9,7 +11,7 @@ define([ //'/common/test.js', 'css!/bower_components/components-font-awesome/css/font-awesome.min.css', -], function ($, Cryptpad, Login, UI, Realtime, Feedback, LocalStore/*, Test */) { +], function (Config, $, h, Cryptpad, Login, UI, Realtime, Feedback, LocalStore/*, Test */) { if (window.top !== window) { return; } $(function () { var $checkImport = $('#import-recent'); @@ -19,6 +21,27 @@ define([ return; } + if (Config.sso) { + // TODO + // Config.sso.force => no legacy login allowed + // Config.sso.password => cp password required or forbidden + // Config.sso.list => list of configured identity providers + var $sso = $('div.cp-login-sso'); + var list = Config.sso.list.map(function (name) { + var b = h('button.btn.btn-secondary', name); + var $b = $(b).click(function () { + $b.prop('disabled', 'disabled'); + Login.ssoAuth(name, function (err, data) { + if (data.url) { + window.location.href = data.url; + } + }); + }); + return b; + }); + $sso.append(list); + } + /* Log in UI */ // deferred execution to avoid unnecessary asset loading var loginReady = function (cb) { diff --git a/www/register/main.js b/www/register/main.js index 3900fc855..729518819 100644 --- a/www/register/main.js +++ b/www/register/main.js @@ -62,13 +62,11 @@ define([ var $b = $(b).click(function () { console.log('sso register click:', name); $b.prop('disabled', 'disabled'); - Login.ssoRegister(name, function (err, data) { - console.error(err, data); + Login.ssoAuth(name, function (err, data) { if (data.url) { window.location.href = data.url; } }); - // XXX Server command }); return b; }); @@ -152,7 +150,8 @@ define([ function (yes) { if (!yes) { return; } - Login.loginOrRegisterUI(uname, passwd, true, shouldImport, false /*Test.testing*/, function () { + Login.loginOrRegisterUI(uname, passwd, true, shouldImport, + UI.getOTPScreen, false /*Test.testing*/, function () { if (test) { localStorage.clear(); test.pass(); diff --git a/www/ssoauth/index.html b/www/ssoauth/index.html index a7bdb9b14..11e9eb7b7 100644 --- a/www/ssoauth/index.html +++ b/www/ssoauth/index.html @@ -6,7 +6,9 @@ - + + + diff --git a/www/ssoauth/main.js b/www/ssoauth/main.js index c556e2814..4272d87fa 100644 --- a/www/ssoauth/main.js +++ b/www/ssoauth/main.js @@ -1,3 +1,142 @@ -define(['/customize/login.js'], function (Login) { - Login.ssoRegisterCb(); +define([ + '/api/config', + 'jquery', + '/common/hyperscript.js', + '/common/common-util.js', + '/common/common-credential.js', + '/common/common-interface.js', + '/common/common-login.js', + '/common/common-constants.js', + '/common/outer/http-command.js', + '/common/outer/local-store.js', + '/common/outer/login-block.js', + '/customize/messages.js', + + '/bower_components/tweetnacl/nacl-fast.min.js', +], function (ApiConfig, $, h, Util, Cred, UI, Login, Constants, + ServerCommand, LocalStore, Block, Messages) { + if (window.top !== window) { return; } + + let Nacl = window.nacl; + + let ssoAuthCb = function (cb) { + var b64Keys = Util.tryParse(localStorage.CP_sso_auth); + if (!b64Keys) { + UI.errorLoadingScreen("MISSING_SIGNATURE_KEYS"); + return; + } + var keys = { + secretKey: Nacl.util.decodeBase64(b64Keys.s), + publicKey: Nacl.util.decodeBase64(b64Keys.p) + }; + ServerCommand(keys, { + command: 'SSO_AUTH_CB', + url: window.location.href + }, function (err, data) { + delete localStorage.CP_sso_auth; + document.cookie = 'ssotoken=; Max-Age=-99999999;'; + cb(err, data); + }); + }; + let ssoLoginRegister = function (seed, pw, jwt, name, isRegister) { + Login.loginOrRegister({ + uname: seed, + passwd: pw, + isRegister: isRegister, + onOTP: UI.getOTPScreen, + ssoAuth: { + name: name, + type: 'SSO', + data: jwt + } + }, function (err) { + if (err) { + console.error(err); + return void UI.warn(Messages.error); + } + window.location.href = '/drive/'; + }); + }; + + $(function () { + if (!ApiConfig.sso) { return void UI.errorLoadingScreen(Messages.error); } + + UI.addLoadingScreen(); + ssoAuthCb(function (err, data) { + if (err || !data || !data.jwt) { + console.error(err || 'NO_DATA'); + return void UI.warn(Messages.error); + } + let jwt = data.jwt; + let seed = data.seed; + let name = data.name; + $('body').addClass(data.register ? 'cp-register' : 'cp-login'); + + UI.removeLoadingScreen(); + + + // Login with a password OR register and password allowed + let $button = $('button#cp-ssoauth-button'); + let $pw = $('#password'); + let $pw2 = $('#passwordconfirm'); + let next = (pw) => { + // TODO login err ==> re-enable button + + setTimeout(() => { // First setTimeout to remove mobile devices' keyboard + UI.addLoadingScreen({ + loadingText: Messages.login_hashing, + hideTips: true, + }); + setTimeout(function () { // Second timeout for the loading screen befofe Scrypt + ssoLoginRegister(seed, pw, jwt, name, data.register); + }, 100); + }, 100); + + }; + + // Existing account, no CP password, continue + if (!data.register && !data.password) { + return void next(''); + } + + // Registration and CP password disabled, continue + if (data.register && !ApiConfig.sso.password) { + return void next(''); + } + + $('div.cp-ssoauth-pw').show(); + + $button.click(() => { + let pw = $pw.val(); + if (data.register && pw !== $pw2.val()) { + return void UI.warn(Messages.register_passwordsDontMatch); + } + if (data.register && pw && !Cred.isLongEnoughPassword(pw)) { + return void UI.warn(Messages.register_passwordTooShort); + } + $button.prop('disabled', 'disabled'); + + if (data.register) { + var span = h('span', [ + h('h2', [ + h('i.fa.fa-warning'), + ' ', + Messages.register_warning, + ]), + Messages.register_warning_note + ]); + UI.confirm(span, function (yes) { + if (!yes) { + $button.removeAttr('disabled'); + return; + } + next(pw); + }); + return; + } + + next(pw); + }); + }); + }); }); From d6bf625733d1ea43d650167a5b77f3d6625c4962 Mon Sep 17 00:00:00 2001 From: yflory Date: Thu, 29 Jun 2023 12:32:45 +0200 Subject: [PATCH 003/288] SSO: prototype improvements --- lib/challenge-commands/sso.js | 30 ++++++++++++++----------- lib/http-worker.js | 42 +++++++++-------------------------- lib/plugins/sso/oidc.js | 27 ++++++---------------- lib/sso-utils.js | 23 +++++++++---------- lib/storage/sessions.js | 8 ------- lib/storage/sso.js | 23 ++++++++++--------- 6 files changed, 57 insertions(+), 96 deletions(-) diff --git a/lib/challenge-commands/sso.js b/lib/challenge-commands/sso.js index 385fa6d70..51818d23b 100644 --- a/lib/challenge-commands/sso.js +++ b/lib/challenge-commands/sso.js @@ -76,12 +76,13 @@ authCb.complete = function (Env, body, cb, req) { const cfg = getProviderConfig(Env, data.provider); const idp = TYPES[data.type]; const register = data.register; + const provider = data.provider; idp.authCb(Env, cfg, ssotoken, url, (err, obj) => { if (err) { return void cb(err); } const {id, idpData, name} = obj; let next = (user, isRegister) => { - SSOUtils.createJWT(Env, id, data.provider, idpData, (err, jwt) => { + SSOUtils.createJWT(Env, id, provider, idpData, (err, jwt) => { if (err) { return void cb(err); } cb(void 0, { jwt: jwt, @@ -94,19 +95,22 @@ authCb.complete = function (Env, body, cb, req) { }; if (register) { - SSOUtils.writeUser(Env, id, (err, userData) => { + SSOUtils.writeUser(Env, provider, id, (err, userData) => { if (err && err.code === 'EEXIST') { - return void SSOUtils.readUser(Env, id, (err, userData) => { + return void SSOUtils.readUser(Env, provider, id, (err, userData) => { if (err) { return void cb(err); } - next(userData, false); + next(userData, !userData.complete); }); } if (err) { return void cb(err); } next(userData, true); }); } else { - SSOUtils.readUser(Env, id, (err, userData) => { + SSOUtils.readUser(Env, provider, id, (err, userData) => { if (err) { return void cb(err); } + if (!userData || !userData.complete) { + return void cb('NO_USER'); + } next(userData, false); }); } @@ -148,8 +152,8 @@ register.complete = function (Env, body, cb) { payload = _payload; })); }).nThen((w) => { - const { sub } = payload; - SSOUtils.readUser(Env, sub, w((err, user) => { + const { sub, provider } = payload; + SSOUtils.readUser(Env, provider, sub, w((err, user) => { if (err) { w.abort(); console.log(err, sub); @@ -170,10 +174,10 @@ register.complete = function (Env, body, cb) { if (err) { w.abort(); } })); }).nThen((w) => { - if (!pw) { return; } - const { sub } = payload; - ssoUser.password = true; - SSOUtils.updateUser(Env, sub, ssoUser, w()); + const { sub, provider } = payload; + ssoUser.password = Boolean(pw); + ssoUser.complete = true; + SSOUtils.updateUser(Env, provider, sub, ssoUser, w()); }).nThen(() => { const { data, provider } = payload; SSOUtils.makeSession(Env, publicKey, provider, data, cb); @@ -206,8 +210,8 @@ login.complete = function (Env, body, cb) { payload = _payload; })); }).nThen((w) => { - const { sub } = payload; - SSOUtils.readUser(Env, sub, w((err) => { + const { sub, provider } = payload; + SSOUtils.readUser(Env, provider, sub, w((err) => { if (err) { w.abort(); console.log(err, sub); diff --git a/lib/http-worker.js b/lib/http-worker.js index 50fba51c0..97cff40d6 100644 --- a/lib/http-worker.js +++ b/lib/http-worker.js @@ -377,7 +377,7 @@ app.use('/block/', function (req, res, next) { if (mfa_params && !content.mfa) { return void no(); } if (sso_params && !content.sso) { return void no(); } - if (content.mfa && content.mfa.exp && ((+new Date()) > content.mfa.exp)) { + if (content.mfa && content.mfa.exp && (+new Date()) > content.mfa.exp) { Log.error("OTP_SESSION_EXPIRED", content.mfa); // XXX Only delete the mfa part Sessions.delete(Env, name, token, function (err) { @@ -391,41 +391,19 @@ app.use('/block/', function (req, res, next) { } - if (content.sso) { - SSOUtils.checkSession(Env, content.sso, (err, state, newState) => { - if (err || !state) { - // XXX Only delete the mfa part - Sessions.delete(Env, name, token, function (err) { - if (err) { - Log.error('SSO_SESSION_DELETE_EXPIRED_ERROR', err); - return; - } - Log.info('SSO_SESSION_DELETE_EXPIRED', err); - }); - return void no(); + if (content.sso && content.sso.exp && (+new Date()) > content.sso.exp) { + Log.error("SSO_SESSION_EXPIRED", content.sso); + Sessions.delete(Env, name, token, function (err) { + if (err) { + Log.error('SSO_SESSION_DELETE_EXPIRED_ERROR', err); + return; } - if (newState) { - content.sso = newState; - // XXX We should produce a new session ID here and send it back to the user - Sessions.update(Env, name, token, JSON.stringify(content), (err) => { - if (err) { - Log.error('SSO_SESSION_UPDATE_ERROR', err); - return; - } - Log.info('SSO_SESSION_UPDATED', err); - }); - } - next(); + Log.info('SSO_SESSION_DELETE_EXPIRED', err); }); - return; + return void no(); } - // we could also check whether the content of the file matches the token, - // but clients don't have any influence over the reference and can only - // request to create tokens that are scoped to a public key they control. - // I don' think there's any practical benefit to such a check. - - // So, interpret the existence of a file in that location as the continued + // Interpret the existence of a file in that location as the continued // validity of the session. Fall through and let the built-in webserver // handle the 404 or serving the file. next(); diff --git a/lib/plugins/sso/oidc.js b/lib/plugins/sso/oidc.js index caa904a85..8d2d72f3f 100644 --- a/lib/plugins/sso/oidc.js +++ b/lib/plugins/sso/oidc.js @@ -4,7 +4,6 @@ const TYPE = 'oidc'; const getClient = (cfg, cb) => { OID.Issuer.discover(cfg.url).then((issuer) => { // XXX Only once for all users? - console.log('Discovered issuer %s %O', issuer.issuer); const client = new issuer.Client({ client_id: cfg.client_id, client_secret: cfg.client_secret, @@ -55,30 +54,18 @@ module.exports = { expires_at: j.expires_at, access_token: j.access_token, refresh_token: j.refresh_token, - id_token: j.id_token + //id_token: j.id_token // XXX no need to store id_token? } }); }); }); }, - checkSession: (Env, cfg, data, cb) => { - const { refresh_token } = data; - + /* + getData: (Env, cfg, data, cb) => { + // data = { refresh_token, access_token, expires_at } let t = new OID.TokenSet(data); - if (!t.expired()) { return void cb(void 0, true); } - getClient(cfg, (err, client) => { - client.refresh(t.refresh_token).then((j) => { - const newData = { - refresh_token: refresh_token, - id_token: j.id_token, - access_token: j.access_token, - expires_at: j.expires_at - }; - cb(void 0, true, newData); - }, () => { - // Error: can't renew token - cb(void 0, false); - }); - }); + // TODO get userinfo using access_token + // use refresh_token if access expired } + */ }; diff --git a/lib/sso-utils.js b/lib/sso-utils.js index 3e11047e2..9a6a069fb 100644 --- a/lib/sso-utils.js +++ b/lib/sso-utils.js @@ -6,6 +6,8 @@ const Util = require("./common-util"); const SSOUtils = module.exports; +const SESSION_EXPIRATIION = 12 * 3600 * 1000; // XXX Hours? Days? Weeks? Configurable? + // XXX const SAML = require('node-saml'); // https://www.npmjs.com/package/node-saml const TYPES = SSOUtils.TYPES = { oidc: require('./plugins/sso/oidc') @@ -29,12 +31,6 @@ SSOUtils.isValidConfig = (cfg) => { return idp.checkConfig(cfg); }; -SSOUtils.checkSession = (Env, sessionData, cb) => { - const cfg = getProviderConfig(Env, sessionData.provider); - const idp = TYPES[cfg.type.toLowerCase()]; - idp.checkSession(Env, cfg, sessionData.data, cb); -}; - SSOUtils.deleteRequest = (Env, id) => { SSO.request.delete(Env, id, (err) => { if (!err) { return; } @@ -60,9 +56,9 @@ SSOUtils.writeRequest = (Env, data, cb) => { }; -SSOUtils.writeUser = (Env, id, cb) => { +SSOUtils.writeUser = (Env, provider, id, cb) => { const seed = Nacl.util.encodeBase64(Nacl.randomBytes(24)); - SSO.user.write(Env, id, JSON.stringify({ + SSO.user.write(Env, provider, id, JSON.stringify({ seed: seed, password: false }), (err) => { @@ -70,15 +66,15 @@ SSOUtils.writeUser = (Env, id, cb) => { cb(void 0, { seed }); }); }; -SSOUtils.readUser = (Env, id, cb) => { - SSO.user.read(Env, id, (err, user) => { +SSOUtils.readUser = (Env, provider, id, cb) => { + SSO.user.read(Env, provider, id, (err, user) => { if (err) { return void cb(err); } cb(void 0, Util.tryParse(user)); }); }; -SSOUtils.updateUser = (Env, id, data, cb) => { - SSO.user.delete(Env, id, () => { - SSO.user.write(Env, id, JSON.stringify(data), (err) => { +SSOUtils.updateUser = (Env, provider, id, data, cb) => { + SSO.user.delete(Env, provider, id, () => { + SSO.user.write(Env, provider, id, JSON.stringify(data), (err) => { if (err) { return void cb(err); } cb(); }); @@ -146,6 +142,7 @@ SSOUtils.makeSession = (Env, publicKey, provider, ssoData, cb) => { // XXX If we already have an OTP session, recover it Sessions.write(Env, publicKey, sessionId, JSON.stringify({ sso: { + exp: +new Date() + SESSION_EXPIRATIION, provider: provider, data: ssoData } diff --git a/lib/storage/sessions.js b/lib/storage/sessions.js index 30283dc47..f31151ec8 100644 --- a/lib/storage/sessions.js +++ b/lib/storage/sessions.js @@ -42,14 +42,6 @@ Sessions.delete = function (Env, id, ref, cb) { Basic.delete(Env, path, cb); }; -Sessions.update = function (Env, id, ref, data, cb) { - var path = pathFromId(Env, id, ref); - Basic.delete(Env, path, (err) => { - if (err) { return void cb(err); } - Basic.write(Env, path, data, cb); - }); -}; - Sessions.deleteUser = function (Env, id, cb) { if (!id || typeof(id) !== 'string') { return; } id = Util.escapeKeyCharacters(id); diff --git a/lib/storage/sso.js b/lib/storage/sso.js index b28203fd5..3e41bb5d8 100644 --- a/lib/storage/sso.js +++ b/lib/storage/sso.js @@ -21,13 +21,17 @@ var pathFromId = function (Env, id, subPath) { var reqPathFromId = function (Env, id) { return pathFromId(Env, id, 'sso_request'); }; -var userPathFromId = function (Env, id) { - return pathFromId(Env, id, 'sso_user'); -}; var blockPathFromId = function (Env, id) { return pathFromId(Env, id, 'sso_block'); }; +var userPathFromId = function (Env, id, provider) { + if (!id || typeof(id) !== 'string') { return; } + if (!provider || typeof(provider) !== 'string') { return; } + id = Util.escapeKeyCharacters(id); + return Path.join(Env.paths.base, 'sso_user', provider, id.slice(0, 2), `${id}.json`); +}; + const Req = SSO.request = {}; Req.read = function (Env, id, cb) { @@ -36,7 +40,6 @@ Req.read = function (Env, id, cb) { }; Req.write = function (Env, id, data, cb) { var path = reqPathFromId(Env, id); - console.log(path); Basic.write(Env, path, data, cb); }; Req.delete = function (Env, id, cb) { @@ -47,16 +50,16 @@ Req.delete = function (Env, id, cb) { const User = SSO.user = {}; -User.read = function (Env, id, cb) { - var path = userPathFromId(Env, id); +User.read = function (Env, provider, id, cb) { + var path = userPathFromId(Env, id, provider); Basic.read(Env, path, cb); }; -User.write = function (Env, id, data, cb) { - var path = userPathFromId(Env, id); +User.write = function (Env, provider, id, data, cb) { + var path = userPathFromId(Env, id, provider); Basic.write(Env, path, data, cb); }; -User.delete = function (Env, id, cb) { - var path = userPathFromId(Env, id); +User.delete = function (Env, provider, id, cb) { + var path = userPathFromId(Env, id, provider); Basic.delete(Env, path, cb); }; From d1d26571cfdb4ac658f61c47b5bbccf573d832c5 Mon Sep 17 00:00:00 2001 From: yflory Date: Sun, 2 Jul 2023 12:04:21 +0300 Subject: [PATCH 004/288] SSO: fix issue with missing config --- lib/http-worker.js | 2 +- www/common/common-login.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/http-worker.js b/lib/http-worker.js index 97cff40d6..50bb30532 100644 --- a/lib/http-worker.js +++ b/lib/http-worker.js @@ -465,7 +465,7 @@ var makeRouteCache = function (template, cacheName) { }; const ssoList = Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) && - Env.sso.list.map(function (obj) { return obj.name; }); + Env.sso.list.map(function (obj) { return obj.name; }) || []; const ssoCfg = ssoList.length ? { force: (Env.sso && Env.sso.enforced && 1) || 0, password: (Env.sso && Env.sso.cpPassword && 1) || 0, diff --git a/www/common/common-login.js b/www/common/common-login.js index 007c0bbfc..e2287de46 100644 --- a/www/common/common-login.js +++ b/www/common/common-login.js @@ -297,7 +297,7 @@ define([ register: isRegister, uname: uname }; - if (ssoAuth && ssoAuth.name) { res.uname = ssoAuth.name; } + if (ssoAuth && ssoAuth.name) { uname = res.uname = ssoAuth.name; } var RT, blockKeys, blockUrl; From 29609212be9903a8bcebd6781a4e7450b95f48e6 Mon Sep 17 00:00:00 2001 From: yflory Date: Thu, 31 Aug 2023 16:34:57 +0200 Subject: [PATCH 005/288] Reorder DOM based on visual order in toolbar #1198 --- customize.dist/src/less2/include/toolbar.less | 42 ------ www/common/toolbar.js | 124 +++++++++--------- www/file/inner.js | 14 +- 3 files changed, 67 insertions(+), 113 deletions(-) diff --git a/customize.dist/src/less2/include/toolbar.less b/customize.dist/src/less2/include/toolbar.less index b1eb49887..18599951c 100644 --- a/customize.dist/src/less2/include/toolbar.less +++ b/customize.dist/src/less2/include/toolbar.less @@ -439,13 +439,6 @@ screen and (max-height: 500px) { flex-wrap: wrap; height: @toolbar_line-height; - .cp-pad-not-pinned { - line-height: 32px; - flex: unset; - padding: 0; - align-self: auto; - margin: 0 5px; - } .cp-toolbar-top-filler { height: 32px; } @@ -578,41 +571,6 @@ position: relative; width: 100%; - .cp-pad-not-pinned { - order: 4; - flex: 1; - - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - - line-height: @toolbar_top-height; - padding: 0; - margin: 0 5px; - font-size: @colortheme_app-font-size; - color: @cp_toolbar-warn; - .cp-pnp-msg { - padding-left: 5px; - font-family: @colortheme_font; - font-size: @colortheme_app-font-size; - a { - font-size: @colortheme_app-font-size; - font-family: @colortheme_font; - font-weight: bold; - color: @cp_toolbar-warn; - &:hover { - text-decoration: underline; - } - } - @media screen and (max-width: (@browser_media-not-big)) { - display: none; - } - } - @media screen and (max-width: (@browser_media-not-big)) { - overflow: visible; - max-width: 20px; - } - } .cp-toolbar-top-filler { height: @toolbar_top-height; display: inline-block; diff --git a/www/common/toolbar.js b/www/common/toolbar.js index c1595cd2c..227aba017 100644 --- a/www/common/toolbar.js +++ b/www/common/toolbar.js @@ -61,6 +61,50 @@ MessengerUI, Messages, Pages) { return 'cp-toolbar-uid-' + String(Math.random()).substring(2); }; + var observeChildren = function ($content) { + var reorderDOM = Util.throttle(function ($content, observer) { + if (!$content.length) { return; } + + // List all children based on their "order" property + var map = {}; + $content[0].childNodes.forEach((node) => { + try { + if (!node.attributes) { return; } + var order = getComputedStyle(node).getPropertyValue("order"); + var a = map[order] = map[order] || []; + a.push(node); + } catch (e) { console.error(e, node); } + }); + + // Disconnect the observer while we're reordering to avoid infinite loop + observer.disconnect(); + Object.keys(map).sort(function (a, b) { + return Number(a) - Number(b); + }).forEach(function (k) { + var arr = map[k]; + // Reorder + arr.forEach(function (node) { + $content.append(node); + }); + }); + observer.start(); + }, 100); + + let observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if (mutation.addedNodes.length) { + reorderDOM($content, observer); + } + }); + }); + observer.start = function () { + observer.observe($content[0], { + childList: true + }); + }; + observer.start(); + }; + var createRealtimeToolbar = function (config) { if (!config.$container) { return; } var $container = config.$container; @@ -103,10 +147,7 @@ MessengerUI, Messages, Pages) { h('i.fa.fa-file-o'), h('span.cp-button-name', Messages.toolbar_file) ])).appendTo($file).hide(); - var $drawerContent = $('
', { - 'class': DRAWER_CLS, - 'tabindex': 1 - }).hide(); + var $drawerContent = $(h('div.'+ DRAWER_CLS, {tabindex: 1})).hide(); UI.createDrawer($drawer, $drawerContent); } @@ -776,58 +817,6 @@ MessengerUI, Messages, Pages) { return $titleContainer; }; - var createUnpinnedWarning0 = function (toolbar, config) { - if (true) { return; } // stub this call since it won't make it into the next release - if (Common.isLoggedIn()) { return; } - var pd = config.metadataMgr.getPrivateData(); - var o = pd.origin; - var cid = pd.channel; - Common.sendAnonRpcMsg('IS_CHANNEL_PINNED', cid, function (x) { - if (x.error || !Array.isArray(x.response)) { return void console.log(x); } - if (x.response[0] === true) { - $('.cp-pad-not-pinned').remove(); - return; - } - - if (typeof(ApiConfig.inactiveTime) !== 'number') { - $('.cp-pad-not-pinned').remove(); - return; - } - - if ($('.cp-pad-not-pinned').length) { return; } - var pnpTitle = Messages._getKey('padNotPinnedVariable', ['','','','', ApiConfig.inactiveTime]); - var pnpMsg = Messages._getKey('padNotPinnedVariable', [ - '', - '', - '', - ApiConfig.inactiveTime - ]); - var $msg = $('', { - 'class': 'cp-pad-not-pinned' - }).append([ - $('', {'class': 'fa fa-exclamation-triangle', 'title': pnpTitle}), - $('', {'class': 'cp-pnp-msg'}).append(pnpMsg) - ]); - $msg.find('a.cp-pnp-login').click(function (ev) { - ev.preventDefault(); - Common.setLoginRedirect('login'); - }); - $msg.find('a.cp-pnp-register').click(function (ev) { - ev.preventDefault(); - Common.setLoginRedirect('register'); - }); - $('.cp-toolbar-top').append($msg); - //UI.addTooltips(); - }); - }; - - var createUnpinnedWarning = function (toolbar, config) { - config.metadataMgr.onChange(function () { - createUnpinnedWarning0(toolbar, config); - }); - createUnpinnedWarning0(toolbar, config); - }; var createPageTitle = function (toolbar, config) { if (!config.pageTitle) { return; } @@ -840,9 +829,13 @@ MessengerUI, Messages, Pages) { var $hoverable = $('', {'class': 'cp-toolbar-title-hoverable'}).appendTo($titleContainer); // Buttons - $('', { + var $b = $('', { 'class': 'cp-toolbar-title-value cp-toolbar-title-value-page' }).appendTo($hoverable).text(config.pageTitle); + + toolbar.updatePageTitle = function (title) { + $b.text(title); + }; }; var createLinkToMain = function (toolbar, config) { @@ -1328,6 +1321,17 @@ MessengerUI, Messages, Pages) { toolbar.$drawer = $toolbar.find('.'+Bar.constants.drawer); toolbar.$top = $toolbar.find('.'+Bar.constants.top); toolbar.$history = $toolbar.find('.'+Bar.constants.history); + toolbar.$user = $toolbar.find('.'+Bar.constants.userAdmin); + + observeChildren(toolbar.$drawer); + observeChildren(toolbar.$bottomL); + observeChildren(toolbar.$bottomM); + observeChildren(toolbar.$bottomR); + observeChildren(toolbar.$top); + observeChildren(toolbar.$user); + if (config.$contentContainer) { + observeChildren(config.$contentContainer); + } toolbar.$userAdmin = $toolbar.find('.'+Bar.constants.userAdmin); @@ -1342,14 +1346,10 @@ MessengerUI, Messages, Pages) { tb['title'] = createTitle; tb['pageTitle'] = createPageTitle; //tb['request'] = createRequest; - tb['lag'] = $.noop; tb['spinner'] = createSpinner; - tb['state'] = $.noop; tb['limit'] = createLimit; // TODO - tb['upgrade'] = $.noop; tb['newpad'] = createNewPad; tb['useradmin'] = createUserAdmin; - tb['unpinnedWarning'] = createUnpinnedWarning; tb['notifications'] = createNotifications; tb['maintenance'] = createMaintenance; @@ -1359,7 +1359,7 @@ MessengerUI, Messages, Pages) { 'chat', 'collapse', 'userlist', 'title', 'useradmin', 'spinner', - 'newpad', 'share', 'access', 'limit', 'unpinnedWarning', + 'newpad', 'share', 'access', 'limit', 'notifications' ], {}); }; diff --git a/www/file/inner.js b/www/file/inner.js index 00e0a2fa9..b635b3e50 100644 --- a/www/file/inner.js +++ b/www/file/inner.js @@ -55,7 +55,7 @@ define([ } var Title = common.createTitle({}); - var displayed = ['useradmin', 'newpad', 'limit', 'upgrade', 'notifications']; + var displayed = ['useradmin', 'newpad', 'limit', 'upgrade', 'notifications', 'pageTitle']; if (!uploadMode) { displayed.push('fileshare'); displayed.push('access'); @@ -64,12 +64,9 @@ define([ displayed: displayed, $container: $bar, metadataMgr: metadataMgr, + pageTitle: Messages.upload_title, sfCommon: common, }; - if (uploadMode) { - displayed.push('pageTitle'); - configTb.pageTitle = Messages.upload_title; - } var toolbar = APP.toolbar = Toolbar.create(configTb); if (!uploadMode) { @@ -136,10 +133,9 @@ define([ common.setPadAttribute('fileType', metadata.type); } - toolbar.addElement(['pageTitle'], { - pageTitle: title, - title: Title.getTitleConfig(), - }); + if (toolbar.updatePageTitle) { + toolbar.updatePageTitle(title); + } toolbar.$drawer.append(common.createButton('forget', true)); toolbar.$drawer.append(common.createButton('properties', true)); if (common.isLoggedIn()) { From 784a833a0f6d784fd3889f87425850bc158ba3c5 Mon Sep 17 00:00:00 2001 From: yflory Date: Mon, 4 Sep 2023 12:03:37 +0200 Subject: [PATCH 006/288] Fix CkEditor issue with toolbar reordering --- www/common/toolbar.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/common/toolbar.js b/www/common/toolbar.js index 227aba017..19bb768ca 100644 --- a/www/common/toolbar.js +++ b/www/common/toolbar.js @@ -82,6 +82,7 @@ MessengerUI, Messages, Pages) { return Number(a) - Number(b); }).forEach(function (k) { var arr = map[k]; + if (!Number(k)) { return; } // No need to "append" if order is 0 // Reorder arr.forEach(function (node) { $content.append(node); From 1f6b1169938fa47628fdb910e89aefc4d72a0660 Mon Sep 17 00:00:00 2001 From: daria Date: Mon, 4 Sep 2023 16:09:32 +0300 Subject: [PATCH 007/288] user menu can be accessed using the keyboard on firefox #1209 --- www/common/common-ui-elements.js | 35 +++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index a8992250c..dfeaa8049 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -1539,11 +1539,14 @@ define([ $el.appendTo($innerblock); if (typeof(o.action) === 'function') { - $el.click(function (e) { - var close = o.action(e); - if (close) { hide(); } + $el.on('click keydown', function (e) { + if (e.type === 'click' || (e.type === 'keydown' && e.keyCode === 13)) { + var close = o.action(e); + if (close) { hide(); } + } }); } + }); }; setOptions(config.options); @@ -1821,7 +1824,7 @@ define([ if (accountName && !AppConfig.disableProfile) { options.push({ tag: 'a', - attributes: {'class': 'cp-toolbar-menu-profile fa fa-user-circle'}, + attributes: {'class': 'cp-toolbar-menu-profile fa fa-user-circle','tabindex': '0'}, content: h('span', Messages.profileButton), action: function () { if (padType) { @@ -1836,7 +1839,8 @@ define([ options.push({ tag: 'a', attributes: { - 'class': 'fa fa-hdd-o' + 'class': 'fa fa-hdd-o', + 'tabindex': '0' }, content: h('span', Messages.type.drive), action: function () { @@ -1848,7 +1852,8 @@ define([ options.push({ tag: 'a', attributes: { - 'class': 'fa fa-users' + 'class': 'fa fa-users', + 'tabindex': '0' }, content: h('span', Messages.type.teams), action: function () { @@ -1861,6 +1866,7 @@ define([ tag: 'a', attributes: { 'class': 'fa fa-calendar', + 'tabindex': '0' }, content: h('span', Messages.calendar), action: function () { @@ -1872,7 +1878,8 @@ define([ options.push({ tag: 'a', attributes: { - 'class': 'fa fa-address-book' + 'class': 'fa fa-address-book', + 'tabindex': '0' }, content: h('span', Messages.type.contacts), action: function () { @@ -1883,7 +1890,7 @@ define([ if (padType !== 'settings') { options.push({ tag: 'a', - attributes: {'class': 'cp-toolbar-menu-settings fa fa-cog'}, + attributes: {'class': 'cp-toolbar-menu-settings fa fa-cog','tabindex': '0'}, content: h('span', Messages.settingsButton), action: function () { if (padType) { @@ -1900,7 +1907,7 @@ define([ if (priv.edPublic && Array.isArray(Config.adminKeys) && Config.adminKeys.indexOf(priv.edPublic) !== -1) { options.push({ tag: 'a', - attributes: {'class': 'cp-toolbar-menu-admin fa fa-cogs'}, + attributes: {'class': 'cp-toolbar-menu-admin fa fa-cogs','tabindex': '0'}, content: h('span', Messages.adminPage || 'Admin'), action: function () { if (padType) { @@ -1940,6 +1947,7 @@ define([ tag: 'a', attributes: { 'class': 'cp-toolbar-about fa fa-info', + 'tabindex': '0' }, content: h('span', Messages.user_about), action: function () { @@ -1950,7 +1958,8 @@ define([ options.push({ tag: 'a', attributes: { - 'class': 'fa fa-home' + 'class': 'fa fa-home', + 'tabindex': '0' }, content: h('span', Messages.homePage), action: function () { @@ -1991,7 +2000,8 @@ define([ options.push({ tag: 'a', attributes: { - 'class': 'fa fa-gift' + 'class': 'fa fa-gift', + 'tabindex': '0' }, content: h('span', Messages.crowdfunding_button2), action: function () { @@ -2022,6 +2032,7 @@ define([ tag: 'a', attributes: { 'class': 'cp-toolbar-menu-logout-everywhere fa fa-plug', + 'tabindex': '0' }, content: h('span', Messages.logoutEverywhere), action: function () { @@ -2035,7 +2046,7 @@ define([ }); options.push({ tag: 'a', - attributes: {'class': 'cp-toolbar-menu-logout fa fa-sign-out'}, + attributes: {'class': 'cp-toolbar-menu-logout fa fa-sign-out','tabindex': '0'}, content: h('span', Messages.logoutButton), action: function () { Common.logout(function () { From bd6de021e2df8d3956c707374bd4495ba6d449ce Mon Sep 17 00:00:00 2001 From: daria Date: Tue, 5 Sep 2023 14:42:42 +0300 Subject: [PATCH 008/288] added focus to toolbar elements #1206 user menu can be also accessed on other browsers #1209 --- customize.dist/src/less2/include/toolbar.less | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/customize.dist/src/less2/include/toolbar.less b/customize.dist/src/less2/include/toolbar.less index 18599951c..118fa8288 100644 --- a/customize.dist/src/less2/include/toolbar.less +++ b/customize.dist/src/less2/include/toolbar.less @@ -363,12 +363,14 @@ * { outline-width: 0; &:focus { - outline-width: 0; + outline-width: 1rem; + // color shows on chrome/edge, but not firefox + outline-color: grey; // XXX temporary color to change } } box-sizing: border-box; - padding: 0px; + padding: 0; display: flex; flex-wrap: wrap; justify-content: space-between; @@ -1219,3 +1221,4 @@ } } + From 71ec6d50f92c44c4ce9586bf68b082dbac270ff2 Mon Sep 17 00:00:00 2001 From: daria Date: Tue, 5 Sep 2023 15:52:17 +0300 Subject: [PATCH 009/288] `Open Notification panel` can be accessed with the keyboard #1201 --- customize.dist/src/less2/include/drive.less | 6 +++--- www/common/toolbar.js | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/customize.dist/src/less2/include/drive.less b/customize.dist/src/less2/include/drive.less index 4fea4e43a..afb77243e 100644 --- a/customize.dist/src/less2/include/drive.less +++ b/customize.dist/src/less2/include/drive.less @@ -128,9 +128,9 @@ } } - div:focus { - outline: none; - } + //div:focus { + // outline: none; + //} .fa { font-family: FontAwesome; diff --git a/www/common/toolbar.js b/www/common/toolbar.js index 19bb768ca..3ad574b40 100644 --- a/www/common/toolbar.js +++ b/www/common/toolbar.js @@ -1068,9 +1068,11 @@ MessengerUI, Messages, Pages) { var createNotifications = function (toolbar, config) { var $notif = toolbar.$top.find('.'+NOTIFICATIONS_CLS).show(); - var openNotifsApp = h('div.cp-notifications-gotoapp', h('p', Messages.openNotificationsApp || "Open notifications App")); - $(openNotifsApp).click(function () { - Common.openURL("/notifications/"); + var openNotifsApp = h('div.cp-notifications-gotoapp',{ tabindex: '0' }, h('p', Messages.openNotificationsApp || "Open notifications App")); + $(openNotifsApp).on('click keypress', function (event) { + if (event.type === 'click' || (event.type === 'keypress' && event.which === 13)) { + Common.openURL("/notifications/"); + } }); var div = h('div.cp-notifications-container', [ h('div.cp-notifications-empty', Messages.notifications_empty) From 154a550b3e9eb93f914b462a971e94db9bee2259 Mon Sep 17 00:00:00 2001 From: daria Date: Tue, 5 Sep 2023 16:04:42 +0300 Subject: [PATCH 010/288] `Allow notifications` can be accessed with the keyboard #1201 --- www/common/toolbar.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/www/common/toolbar.js b/www/common/toolbar.js index 3ad574b40..98d4531f6 100644 --- a/www/common/toolbar.js +++ b/www/common/toolbar.js @@ -1082,15 +1082,18 @@ MessengerUI, Messages, Pages) { var metadataMgr = config.metadataMgr; var privateData = metadataMgr.getPrivateData(); if (!privateData.notifications) { - var allowNotif = h('div.cp-notifications-gotoapp', h('p', Messages.allowNotifications)); + var allowNotif = h('div.cp-notifications-gotoapp',{ tabindex: '0' }, h('p', Messages.allowNotifications)); pads_options.unshift(h("hr")); pads_options.unshift(allowNotif); - var $allow = $(allowNotif).click(function () { - Common.getSframeChannel().event('Q_ASK_NOTIFICATION', null, function (e, allow) { - if (!allow) { return; } - $(allowNotif).remove(); - }); + $(allowNotif).on('click keypress', function (event) { + if (event.type === 'click' || (event.type === 'keypress' && event.which === 13)) { + Common.getSframeChannel().event('Q_ASK_NOTIFICATION', null, function (e, allow) { + if (!allow) { return; } + $(allowNotif).remove(); + }); + } }); + var onChange = function () { var privateData = metadataMgr.getPrivateData(); if (!privateData.notifications) { return; } From c8c46d11985a0e0e316de51e05fa86996704a62c Mon Sep 17 00:00:00 2001 From: daria Date: Tue, 5 Sep 2023 16:36:12 +0300 Subject: [PATCH 011/288] notifications can be accessed with the keyboard #1201 --- www/common/sframe-common-mailbox.js | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/www/common/sframe-common-mailbox.js b/www/common/sframe-common-mailbox.js index 4b9fd9e85..625fc2f07 100644 --- a/www/common/sframe-common-mailbox.js +++ b/www/common/sframe-common-mailbox.js @@ -85,13 +85,17 @@ define([ }); } var order = -Math.floor((Util.find(data, ['content', 'msg', 'ctime']) || 0) / 1000); + const tabIndexValue = data.content.isDismissible ? undefined : '0'; notif = h('div.cp-notification', { style: 'order:'+order+';', 'data-hash': data.content.hash }, [ avatar, - h('div.cp-notification-content', - h('p', formatData(data))) + h('div.cp-notification-content', { + tabindex: tabIndexValue + }, [ + h('p', formatData(data)) + ]) ]); if (typeof(data.content.getFormatText) === "function") { @@ -108,16 +112,24 @@ define([ } if (data.content.isClickable) { - $(notif).find('.cp-notification-content').addClass("cp-clickable") - .click(data.content.handler); + $(notif).find('.cp-notification-content').addClass("cp-clickable").on('click keypress', function (event) { + if (event.type === 'click' || (event.type === 'keypress' && event.which === 13)) { + data.content.handler(); + } + }); } if (data.content.isDismissible) { var dismissIcon = h('span.fa.fa-times'); var dismiss = h('div.cp-notification-dismiss', { - title: Messages.notifications_dismiss + title: Messages.notifications_dismiss, + tabindex: '0' }, dismissIcon); $(dismiss).addClass("cp-clickable") - .click(data.content.dismissHandler); + .on('click keypress', function (event) { + if (event.type === 'click' || (event.type === 'keypress' && event.which === 13)) { + data.content.dismissHandler(); + } + }); $(notif).append(dismiss); } return notif; From dfa2da23071cd618ebff8b64b577b8124af36519 Mon Sep 17 00:00:00 2001 From: daria Date: Thu, 14 Sep 2023 15:26:27 +0300 Subject: [PATCH 012/288] changed `div` to `ul` #1192 --- www/common/drive-ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/common/drive-ui.js b/www/common/drive-ui.js index 11908cf38..0cc70ed12 100644 --- a/www/common/drive-ui.js +++ b/www/common/drive-ui.js @@ -3452,7 +3452,7 @@ define([ // Create the ghost icon to add pads/folders var createNewPadIcons = function ($block, isInRoot) { - var $container = $('
'); + var $container = $('