/** * @fileoverview * Validates an email ID and password(s) * Prepares "captcha" data * Transmits login credentials to Doors for login or register (fun callDoors) * * @todo * - package.json * * @version 1 * @copyright ISCPIF-CNRS 2016 * @author romain.loth@iscpif.fr * * @requires comex_user_shared * @requires realperson (keith-wood.name/realPerson.html) */ // cmxClt module augmentation // POSS remove cC.auth. namespace prefix from local scope vars cmxClt = (function(cC) { // common vars to authenticating/registering in user area cC.uauth = {} // #doors_connect.value ~~> like a @classparam for uauthforms // :str: "doors_hostname:doors_port" cC.uauth.doorsConnectParam = document.getElementById('doors_connect').value // param for "realperson" widget generation & validation cC.uauth.realCaptchaLength = 5 // our functions cC.uauth.AuthForm cC.uauth.collectCaptcha cC.uauth.testMailFormatAndExistence cC.uauth.showEmailGUIEffects // helper for testMail cC.uauth.doubleCheck cC.uauth.callDoors cC.uauth.callUserApi // AuthForm: init(id, onchange, params) // -------- // @id // @params: // - type login|register|doorsRegister // - emailId html email input element id // - duuidId html doors_uid hidden input element id // - passId html password input element id // - pass2Id optional // - captchaId optional // - capcheckId optional // - all other params are passed to super() // (optional: - mainMessageId // optional: - timestampId // optional: - submitBtnId // optional: - multiTextinputs) cC.uauth.AuthForm = function(aFormId, aValidationFun, afParams) { if (!afParams) afParams = {} // "super" var auForm = cC.uform.Form(aFormId, aValidationFun, afParams) // var auForm = {'id':aFormId, 'elForm':document.getElementById(aFormId)} auForm.emailStatus = null auForm.passStatus = null auForm.captchaStatus = null // -> type auForm.type = afParams.type || "login" if (afParams.validateEmail != undefined) { auForm.validateEmail = afParams.validateEmail } else { auForm.validateEmail = true } if (afParams.validateCaptcha != undefined) { auForm.validateCaptcha = afParams.validateCaptcha } else { auForm.validateCaptcha = false } // -> interaction elements (params, else default) var emailId, duuidId, passId, pass2Id, captchaId, capcheckId // console.info('new AuthForm "'+auForm.id+'"[.type='+auForm.type+'] init params', afParams) emailId = afParams.emailId || 'email' duuidId = afParams.duuidId || 'doors_uid' passId = afParams.passId || 'password' pass2Id = afParams.pass2Id || 'password2' captchaId = afParams.captchaId || 'my-captcha' capcheckId = afParams.capcheckId || 'my-captchaHash' // keep them as properties auForm.elDuuid = document.getElementById(duuidId) auForm.elEmail = document.getElementById(emailId) auForm.elPass = document.getElementById(passId) auForm.elPass2 = document.getElementById(pass2Id) auForm.elCaptcha = document.getElementById(captchaId) auForm.elCapcheck = document.getElementById(capcheckId) // individual event/function bindings ----------- // 1) for email // side-effects: email icon + message if (auForm.validateEmail) { auForm.elEmail.onkeyup = function(event) { // console.debug('..elMail '+auForm.id+' event:'+event.type) cC.uauth.testMailFormatAndExistence(auForm) } auForm.elEmail.onchange = auForm.elEmail.onkeyup } // 2) for password // login <=> just test password's length if (auForm.type == 'login') { auForm.elPass.onkeyup = function(event) { // console.debug("..elPass "+auForm.id+" event:"+event.type) auForm.passStatus = (auForm.elPass.value.length > 7) } auForm.elPass.onchange = auForm.elPass.onkeyup } // register cases <=> do pass 1 and pass 2 match? else { // retrieve message element auForm.pass2Dials = {} var passQ = cC.findAncestor(auForm.elPass2, 'question') auForm.pass2Dials.elMsg = passQ.querySelector('.umessage') // bind doubleCheck auForm.elPass.onkeyup = function(event) { // console.debug('..elPass '+auForm.id+' event:'+event.type) cC.uauth.doubleCheck(auForm) } auForm.elPass.onchange = auForm.elPass.onkeyup auForm.elPass2.onkeyup = auForm.elPass.onkeyup auForm.elPass2.onchange = auForm.elPass.onkeyup // we add blocking the button to form submit() prerequisites var blockButton = function() { console.log('blocking submit button') auForm.elSubmitBtn.disabled = true } auForm.preSubmitActions.push(blockButton) } // 3) for captcha if (auForm.validateCaptcha) { if (auForm.elCaptcha) { // NB this captcha init requires *jquery* $(auForm.elCaptcha).realperson( {length: cC.uauth.realCaptchaLength} ) // so... the events auForm.elCaptcha.onkeyup = function(event) { // console.debug('..elCaptcha '+auForm.id+' event:'+event.type) auForm.captchaStatus = (auForm.elCaptcha.value.length == cC.uauth.realCaptchaLength) } auForm.elCaptcha.onchange = auForm.elCaptcha.onkeyup // we add collecting the captcha real value as another preSubmit auForm.preSubmitActions.push( function () { // console.log('collecting captcha data') cmxClt.uauth.collectCaptcha(auForm) } ) } else { console.warn(`validateCaptcha is set to true but there is no captcha in the authentication form #${auForm.id}`) } } // return new obj return auForm } // ------------------- cC.uauth.collectCaptcha = function (uformObj) { uformObj.elCapcheck.value = $(uformObj.elCaptcha).realperson('getHash') // console.debug(' '+uformObj.id+': collected captcha hash ' +uformObj.elCapcheck.value) } // NB removed earlyValidate // => no need for 1 exposed validation function // b/c all 3 checks bound to their elements onchange/onkeyup // ----------- interaction for mailID check via fetch @doors --------------- // function testMailFormatAndExistence // ------------------------------------ // args: // obja: an AuthForm object // // NB for login we only check the doors DB // for registration, we must check both DBs if email is available // effect 1 emailStatus ok/no, and side effect 2 on icon + msg // wrong format ===========================> grey // format ok, doorsStatus != expectExists => red // format ok, doorsStatus == expectExists => green cC.uauth.testMailFormatAndExistence = function (obja) { // PREP-ING if (obja.lastEmailValue == undefined) { obja.lastEmailValue == null } // locate our dials if any and if not already done if (!obja.emailDials) { // = signaling elements (icons, divs) for user feed-back obja.emailDials = {} var emailQ = cC.findAncestor(obja.elEmail, 'question') obja.emailDials.elIcon = emailQ.querySelector('.uicon') obja.emailDials.elMsg = emailQ.querySelector('.umessage') obja.emailDials.elLbl = emailQ.querySelector('label[for='+obja.elEmail.id+']') } // GO TESTS var emailValue = obja.elEmail.value // 0) memo if (obja.emailStatus != null && emailValue == obja.lastEmailValue) { return obja.emailStatus } // 1) tests if email is well-formed // TODO: better extension and allowed chars set var emailFormatOk = /^[-A-z0-9_=.+]+@[-A-z0-9_=.+]+\.[-A-z0-9_=.+]{2,4}$/.test(emailValue) if (! emailFormatOk) { // TODO add to showEmailGUIEffects with a status == "neutral" // restore original lack of message obja.emailDials.elIcon.classList.remove('glyphicon-ban-circle') obja.emailDials.elIcon.classList.remove('glyphicon-ok-circle') obja.emailDials.elIcon.classList.add('glyphicon-question-sign') obja.emailDials.elIcon.style.color = cC.colorGrey obja.emailDials.elMsg.innerHTML = "" obja.emailDials.elMsg.style.fontWeight = "normal" obja.emailDials.elLbl.style.color = "" // new emailStatus obja.emailStatus = false obja.lastEmailValue = emailValue } else { // 2) additional ajax(es) to check login availability // => updates the emailStatus global boolean // => displays an icon // NEW: added a user api check in register case // if type == login <=> just callDoors for an existing ID // if type == register <=> callUserApi and callDoors for a new ID // if type == doorsRegister <=> just callDoors for a new ID // NB using route in doors api/userExists // using route in comex api/user?op=exists // /!\ async cC.uauth.callDoors( "userExists", [emailValue], function(doorsResp) { var doorsUid = doorsResp[0] var doorsMsg = doorsResp[1] if (obja.type == "login") { obja.emailStatus = (doorsMsg == "LoginAlreadyExists") // signals the form change after this input status change // (we're now after async came back, so long after keyup finished) obja.elForm.dispatchEvent(new CustomEvent('change')) // trigger visual side-effects if (obja.emailStatus) { cC.uauth.showEmailGUIEffects(obja, "login recognized") } else { cC.uauth.showEmailGUIEffects(obja, "login not found") } obja.lastEmailValue = emailValue } // similar but one chained call to local api else if (obja.type == "register" || obja.type == "doorsRegister") { // email available on doors side // ----------------------------- if (doorsMsg == ("LoginAvailable")) { if (obja.type == 'doorsRegister') { obja.emailStatus = true obja.elForm.dispatchEvent(new CustomEvent('change')) cC.uauth.showEmailGUIEffects(obja, doorsMsg) obja.lastEmailValue = emailValue } // full register else { // let's see if it's also available on comexdb side cC.uauth.callUserApi( "exists", emailValue, function(boolExists) { obja.emailStatus = !boolExists var guiMsg = boolExists ? 'login already taken in communityexplorer' : 'login available' // signal and trigger obja.elForm.dispatchEvent(new CustomEvent('change')) cC.uauth.showEmailGUIEffects(obja, guiMsg) obja.lastEmailValue = emailValue } ) } } // not available on doors side // --------------------------- else if (doorsMsg == "LoginAlreadyExists") { obja.emailStatus = false // signal and trigger obja.elForm.dispatchEvent(new CustomEvent('change')) cC.uauth.showEmailGUIEffects(obja, "login already taken in ISC services") obja.lastEmailValue = emailValue } // doors error else { console.error("Error with doors connection") } } } ) } } // showEmailGUIEffects(aUform) // // Uses a status (the boolean aUform.emailStatus) to show info in gui // Now as a separate function to call in different callback nesting levels // // TODO A add the status == "neutral" case // TODO B add a status == "ERROR" case cC.uauth.showEmailGUIEffects = function(formObj, ajaxMsg) { // effects on dials if (formObj.emailStatus) { // icon formObj.emailDials.elIcon.style.color = cC.colorGreen formObj.emailDials.elIcon.classList.remove('glyphicon-ban-circle') formObj.emailDials.elIcon.classList.remove('glyphicon-question-sign') formObj.emailDials.elIcon.classList.add('glyphicon-ok-circle') // message in legend formObj.emailDials.elMsg.innerHTML = "OK: "+ajaxMsg formObj.emailDials.elMsg.style.color = cC.colorGreen formObj.emailDials.elMsg.style.fontWeight = "bold" formObj.emailDials.elMsg.style.fontSize = "" formObj.emailDials.elMsg.style.textShadow = cC.strokeWhite formObj.emailDials.elMsg.style.backgroundColor = "" // label formObj.emailDials.elLbl.style.backgroundColor = "" } else { // icon formObj.emailDials.elIcon.style.color = cC.colorOrange formObj.emailDials.elIcon.classList.remove('glyphicon-ok-circle') formObj.emailDials.elIcon.classList.remove('glyphicon-question-sign') formObj.emailDials.elIcon.classList.add('glyphicon-ban-circle') // message in legend formObj.emailDials.elMsg.innerHTML = "Sorry: "+ajaxMsg+" !" formObj.emailDials.elMsg.style.color = cC.colorDarkerOrange formObj.emailDials.elMsg.style.fontWeight = "bold" formObj.emailDials.elMsg.style.fontSize = "110%" // formObj.emailDials.elMsg.style.backgroundColor = "#888" // formObj.emailDials.elMsg.style.textShadow = cC.strokeDeepGrey formObj.emailDials.elMsg.style.textShadow = "" // label formObj.emailDials.elLbl.style.backgroundColor = cC.colorOrange } } // ----------------------------------------------------------------------- // Password validations functions // TODO use a most common passwords lists // args: a AUForm object // we use properties: // - elPass, // - elPass2, // - pass2Dials, // - passStatus // <= "ret value" // 2 in 1: used only for registration cC.uauth.doubleCheck = function (aUForm) { if (aUForm.elPass.value || aUForm.elPass2.value) { var pass1v = aUForm.elPass.value var pass2v = aUForm.elPass2.value if ((pass1v && pass1v.length > 7) || (pass2v && pass2v.length > 7)) { // test values if (pass1v == pass2v) { if (pass1v.match('[^A-z0-9]')) { aUForm.pass2Dials.elMsg.innerHTML = 'Ok valid passwords!' aUForm.passStatus = true } else { aUForm.pass2Dials.elMsg.innerHTML = "Passwords match but don't contain any special characters, please complexify!" aUForm.passStatus = false } } else { aUForm.pass2Dials.elMsg.innerHTML = "The passwords don't match yet." aUForm.passStatus = false } } else { aUForm.pass2Dials.elMsg.innerHTML = "The password is too short (8 chars min)." aUForm.passStatus = false } } if (!aUForm.passStatus) aUForm.pass2Dials.elMsg.style.color = cC.colorRed else aUForm.pass2Dials.elMsg.style.color = cC.colorGreen } /* ------------------ local ajax function ------------------ * @args: * apiOp: 'exists' is the only supported operation atm * * theEmail: an email as search param * * callback: function that will be called after success ONLY * (takes the boolean value of 'exists' response) */ cC.uauth.callUserApi = function(apiOperation, theEmail, callback) { if (apiOperation != 'exists') { console.error('uauth:callUserApi unsupported apiOp:', apiOperation) } else { var urlArgsStr = '' if (typeof(URLSearchParams) != "undefined") { var urlArgs = new URLSearchParams(); urlArgs.append('op', "exists"); urlArgs.append('email', theEmail); urlArgsStr = urlArgs.toString() } // eg safari else { urlArgsStr = ['op=exists', 'email='+theEmail].join('&') } } if (!callback) { callback = function (boolean) { console.log("callUserApi response:", boolean) } } if (window.fetch) { fetch('/services/api/user?' + urlArgsStr) // 1st then() over promise .then(function(response) { if(response.ok) { // unwraps the promise return response.json() } else { console.warn('uauth:callUserApi: Network response was not ok.'); } }) // 2nd then(): takes response.json() from preceding .then(function(bodyJson) { // ex: {'exists': true} callback(bodyJson[apiOperation]) }) // .catch(function(error) { // console.warn('uauth:callUserApi: fetch error:'+error.message); // }); } // also possible using old-style jquery ajax else { $.ajax({ type: 'GET', dataType: "json", url: '/services/api/user?' + urlArgsStr, success: function(data) { // ex: {'exists': true} callback(data[apiOperation]) }, error: function(result) { console.warn('uauth:callUserApi(jquery version) ajax error with result', result) } }); } } /* --------------- doors ajax cors function ---------------- * @args: * apiAction: 'register' or 'user' or 'userExists' => route to doors api * if unknown type, default action is login via doors/api/user * * data: 3-uple with mail, pass, name * * callback: function that will be called after success AND after error * with the return couple * * process * ------- * for userExists synchronous process: we block flag newEmailStatus * until we get a response * TODO: use fetch instead of $.ajax * * returns couple (id, message) * ---------------------------- * ajax success <=> doorsId should be != null except if unknown error * ajax user infos == doorsMsg * * EXPECTED DOORS ANSWER FORMAT * ----------------------------- * { * "status": "login ok", * "userInfo": { * "id": { * "id": "78407900-6f48-44b8-ab37-503901f85458" * }, * "password": "68d23eab21abab38542184e8fca2199d", * "name": "JPP", * "hashAlgorithm": "PBKDF2", * "hashParameters": {"iterations": 1000, "keyLength": 128} * } * } */ cC.uauth.callDoors = function(apiAction, data, callback) { // console.warn("=====> CORS <=====") // console.log("data",data) // console.log("apiAction",apiAction) var doorsUid = null var doorsMsg = null // all mandatory params for doors var mailStr = data[0] var passStr = data[1] var nameStr = data[2] // test params and set defaults if (typeof apiAction != 'string' || (! /user|register|userExists/.test(apiAction))) { console.error('callDoors error: Unknown doors-api action') } if (typeof callback != 'function') { callback = function(retval) { return retval } } var ok = ( (apiAction == 'userExists' && typeof mailStr == 'string' && mailStr) || (apiAction == 'user' && typeof mailStr == 'string' && mailStr && typeof passStr == 'string' && passStr) || (apiAction == 'register' && typeof mailStr == 'string' && mailStr && typeof passStr == 'string' && passStr && typeof nameStr == 'string' && nameStr) ) if (!ok) { doorsMsg = "Invalid parameters in input data (arg #1)" console.warn('DBG callDoors() internal validation failed before ajax') } else { var sendData = { "login": mailStr, "password": passStr, "name": nameStr } var scheme = 'https' $.ajax({ contentType: "application/x-www-form-urlencoded; charset=UTF-8", dataType: 'json', url: scheme + "://"+cC.uauth.doorsConnectParam+"/api/" + apiAction, data: sendData, type: 'POST', success: function(data) { // console.log('response data', data) if (typeof data != 'undefined' && apiAction == 'userExists') { // userExists success case: it's all in the message :) doorsUid = null doorsMsg = data.status } else if (typeof data != 'undefined' && typeof data.userID != undefined && typeof data.status == 'string') { // main success case: get the id doorsUid = data.userID doorsMsg = data.status } else { doorsMsg = "Unknown response for doors apiAction (" + apiAction +"):" doorsMsg += '"' + JSON.stringify(data).substring(0,10) + '..."' } // start the callback callback([doorsUid,doorsMsg]) }, error: function(result) { // console.log(result) if (apiAction == 'user'){ if (result.responseText.match(/"User .+@.+ not found"/)) { doorsMsg = result.responseText.replace(/^"/g, '').replace(/"$/g, '') } else { console.warn("Unhandled error doors login (" + result.responseText +")") } } else if (apiAction == 'register' || apiAction == 'userExists'){ if (typeof result.responseJSON != 'undefined' && typeof result.responseJSON.status == 'string') { doorsMsg = result.responseJSON.status // will be useful in the future (actually doors errs don't have a status message yet) // if doorsMsg == '' } else { doorsMsg = "Unrecognized response from doors /api/"+apiAction+" (response=" + result.responseText + '[' + result.statusText +"])" console.warn(doorsMsg) } } else { doorsMsg = "Unhandled error from unknown doors apiAction (" + apiAction +")" console.error(doorsMsg) } // start the callback callback([doorsUid,doorsMsg]) } }); } } // we return our augmented comex client module return cC })(cmxClt) ; console.log("user shared auth load OK")