mirror of
1
2
Fork 0

Added UI for captcha

This commit is contained in:
Devshh 2020-03-19 13:05:50 +05:30
parent ef8a4d0e87
commit 5d7536ac45
15 changed files with 380 additions and 57 deletions

17
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/src/server.js"
}
]
}

View File

@ -1,29 +1,65 @@
{
"zKsAXcOCNd9U": {
"mat": [
0.0, 0.0, 0.7, 0.7,
0.0, 0.0, 0.0, 0.1,
0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0
0.30000000000000004,
0.5,
0.5,
0.5,
0.30000000000000004,
0.30000000000000004,
0.30000000000000004,
0.5,
0.10000000000000003,
0.10000000000000003,
0.10000000000000003,
0.10000000000000003,
0.10000000000000003,
0.10000000000000003,
0.10000000000000003,
0.10000000000000003
],
"nums": 2
"nums": 6
},
"eFkYZBMofIVG": {
"mat": [
0.0, 0.0, 0.8, 0.4,
0.0, 0.0, 0.0, 0.4,
0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0
0.30000000000000004,
0.30000000000000004,
0.8999999999999999,
0.8999999999999999,
0.30000000000000004,
0.30000000000000004,
0.7,
1.0999999999999999,
-0.09999999999999998,
-0.09999999999999998,
-0.09999999999999998,
-0.09999999999999998,
-0.09999999999999998,
-0.09999999999999998,
-0.09999999999999998,
-0.09999999999999998
],
"nums": 2
"nums": 9
},
"JOAT6FV7VKKi": {
"mat": [
0.7, 0.6, 0.2, 0.7,
1.0, 0.9, 0.5, 0.1,
1.0, 1.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0
0.6,
0.4,
0.20000000000000004,
0.9999999999999999,
0.6,
0.6,
0.7999999999999999,
0.9999999999999999,
-0.19999999999999998,
-0.19999999999999998,
-0.19999999999999998,
-0.19999999999999998,
-0.19999999999999998,
-0.19999999999999998,
-0.19999999999999998,
-0.19999999999999998
],
"nums": 2
"nums": 8
}
}

View File

@ -5,7 +5,7 @@
"main": "src/server.js",
"type": "module",
"scripts": {
"server": "nodemon src/server.js",
"server": "nodemon src/server.js --ignore dev/ --ignore public/ --ignore src/client/",
"dev": "NODE_ENV=development; rollup -c -w",
"build": "NODE_ENV=production; rollup -c",
"test": "echo \"Error: no test specified\" && exit 1"

View File

@ -1,29 +1,89 @@
import UserSession from '../shared/models/UserSession.js';
import initializeSession from './helpers/initializeSession.js';
import getImage from './helpers/getImage.js';
import verifyImage from './helpers/verifyImage.js';
/**
* Create a uCaptcha box
* @param {string} key Website key
* @param {string} websiteKey Website key
* @return {HTMLElement} uCaptcha box
*/
function uCaptchaBox(key) {
function uCaptchaBox(websiteKey) {
const styles = `
#ucaptcha-next {
border: none;
background-color: royalblue;
color: #FFF;
padding: 10px 20px;
text-transform: uppercase;
border-radius: 3px;
}
#ucaptcha-grid {
width: 384px;
height: 384px;
}
#ucaptcha-grid td {
transition: .1s ease;
cursor: pointer;
padding: 10px;
}
#ucaptcha-grid td.selected {
padding: 0;
border: 10px solid white;
}
`;
const styleTag = createElement('style');
styleTag.innerHTML = styles;
document.head.appendChild(styleTag);
const checkbox = createElement('div', {
style: 'cursor:pointer;border-radius:3px;border:2px solid #888;width:25px;height:25px;display:inline-block',
});
checkbox.onclick = function() {
fetch(`https://localhost:444/api/init?k=${key}`)
.then((r)=>r.text())
.then((r)=>r.substr(2))
.then((r)=>JSON.parse(r))
.then((resp)=>{
const session = new UserSession();
session.deserialize(resp);
const captchaBox = createElement('div');
captchaBox.appendChild(checkbox);
const imageContainer = createElement('div', {id: 'ucaptcha-container'});
const image = createElement('img', {id: 'ucaptcha-img', style: 'position:absolute;display:block;z-index:-999'});
imageContainer.appendChild(image);
const btn = createElement('button', {id: 'ucaptcha-next'});
btn.textContent = 'Next';
captchaBox.appendChild(imageContainer);
captchaBox.appendChild(btn);
checkbox.onclick = async function() {
/** @type {import('../shared/models/UserSession.js').UserSession} */
const session = await initializeSession(websiteKey);
const imageGrid = createElement('table', {id: 'ucaptcha-grid'});
for (let i = 0; i < 4; i++) {
const tr = createElement('tr');
for (let ii = 0; ii < 4; ii++) {
const td = createElement('td',
{'style': 'cursor:pointer'});
td.addEventListener('click', (e)=>{
e.target.classList.toggle('selected');
});
tr.appendChild(td);
}
imageGrid.appendChild(tr);
}
btn.addEventListener('click', (e)=>{
verifyImage(session, imageGrid);
getImage(session, captchaBox);
});
imageContainer.appendChild(imageGrid);
getImage(session, captchaBox);
checkbox.setAttribute('style',
checkbox.getAttribute('style') + 'background-color:royalblue;');
};
const captchaBox = createElement('div');
captchaBox.appendChild(checkbox);
return captchaBox;
}

View File

@ -0,0 +1,20 @@
import request from './util/request.js';
import UserSession from '../../shared/models/UserSession.js';
/**
* Instantiate the uCaptcha loop
* @param {UserSession} session
* @param {HTMLElement} captchaBox
*/
export default function instantiateLoop(session, captchaBox) {
console.log(captchaBox);
request(`/api/image?s=${session.sessionId}`, {responseType: 'blob'})
.then((blob)=>{
const imageUrl = URL.createObjectURL(blob);
console.log(imageUrl);
captchaBox
.querySelector('#ucaptcha-container')
.querySelector('#ucaptcha-img')
.setAttribute('src', imageUrl);
});
}

View File

@ -0,0 +1,21 @@
import request from './util/request.js';
import UserSession from '../../shared/models/UserSession.js';
/**
* @param {string} websiteKey
* @return {Promise<UserSession>}
*/
export default function initializeSession(websiteKey) {
return new Promise((resolve, reject)=>{
request(`/api/init?k=${websiteKey}`)
.then((resp)=>{
console.log(resp);
const session = new UserSession();
session.deserialize(resp);
resolve(session);
})
.catch((e)=>{
reject(e);
});
});
}

View File

@ -0,0 +1,49 @@
/**
* @typedef {Object} RequestOptions
* @property {Object<string, any>} [body] Request body
* @property {string} [method] Request method
* @property {XMLHttpRequestResponseType} [responseType] Return raw response from server
*/
/** @type {RequestOptions} */
const defaultOptions = {
method: 'GET',
responseType: 'text', // Text because we will need to substr it.
};
/**
* Make an HTTP request
* @param {string} url
* @param {RequestOptions} options
* @return {Promise}
*/
export default function(url, options) {
console.log(options);
options = Object.assign({}, defaultOptions, options);
console.log(options);
return new Promise((resolve, reject)=>{
const xhr = new XMLHttpRequest();
xhr.open(options.method, url);
xhr.responseType = options.responseType;
xhr.onload = function() {
if (this.status < 400) {
if (options.responseType !== 'text') {
resolve(this.response);
} else {
resolve(JSON.parse(this.responseText.substr(2)));
}
} else {
reject(JSON.parse(this.responseText));
}
};
if (options.method === 'POST') {
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify(options.body));
} else {
xhr.send();
}
});
}

View File

@ -0,0 +1,33 @@
import request from './util/request.js';
import UserSession from '../../shared/models/UserSession.js';
/**
* Verify the image
* @param {UserSession} session
* @param {HTMLElement} captchaGrid
*/
export default async function(session, captchaGrid) {
const tds = captchaGrid.querySelectorAll('td');
const selectedTds = [];
const mat = new Array(tds.length);
for (let i = 0; i < tds.length; i++) {
if (tds[i].classList.contains('selected')) {
mat[i] = 1;
selectedTds.push(tds[i]);
} else {
mat[i] = 0;
}
}
const body = {
s: session.sessionId,
mat,
};
await request('/api/verify', {method: 'POST', body});
selectedTds.map((td)=>{
td.classList.remove('selected');
});
}

View File

@ -2,6 +2,7 @@
* Session storage in IDB
* @typedef {Object} IDBSession
* @property {string} sessionId User session ID
* @property {string} websiteKey Website ID
* @property {string} image Image URL for challenge
* @property {number} score How likely is the user to be a human?
*/

View File

@ -6,19 +6,26 @@ import serveImage from './serveImage.js';
import verifyImage from './verifyImage.js';
import sendJson from '../../helpers/sendJson.js';
import {IMAGES_FOLDER} from '../../R.js';
import {IMAGES_FOLDER, MAX_SESSION_TIME} from '../../R.js';
const router = new express.Router();
router.get('/init', async (req, res)=>{
const websiteKey = req.query.k;
if (!websiteKey) {
res.end();
return;
}
try {
const websiteKey = req.query.k;
if (!websiteKey) {
res.end();
return;
}
const result = await initializeSession(websiteKey);
sendJson(res, result);
const session = await initializeSession(websiteKey, req.cookies);
res.cookie(websiteKey, session.sessionId,
{httpOnly: true, maxAge: MAX_SESSION_TIME * 1000});
sendJson(res, session.serialize());
} catch (e) {
console.error(e);
res.end();
}
});
router.get('/image', async (req, res)=>{
@ -40,8 +47,12 @@ router.get('/image', async (req, res)=>{
router.post('/verify', async (req, res)=>{
const sessionId = req.body.s;
const mat = req.body.mat;
console.log(req.body);
await verifyImage(sessionId, mat);
sendJson(res, {
ok: 1,
});
});
export default router;

View File

@ -14,19 +14,34 @@ import {MAX_SESSION_TIME} from '../../R.js';
/**
* @param {string} websiteKey
* @return {any}
* @param {Object} cookies
* @return {UserSession}
*/
export default async function initializeSession(websiteKey) {
const randomSessionId = randomBytes(8);
export default async function initializeSession(websiteKey, cookies) {
let randomSessionId;
if (cookies[websiteKey]) {
randomSessionId = cookies[websiteKey];
const session = new UserSession();
session.sessionId = randomSessionId;
session.websiteKey = websiteKey;
return session;
} else {
randomSessionId = randomBytes(8);
}
const image = await pickRandomFile();
const session = new UserSession();
session.sessionId = randomSessionId;
const userPayload = session.serialize();
session.websiteKey = websiteKey;
/** @type {IDBSession} */
const idbPayload = {
sessionId: session.sessionId,
websiteKey: session.websiteKey,
image,
score: 0.5,
};
@ -34,5 +49,5 @@ export default async function initializeSession(websiteKey) {
client.setex(
session.sessionId, MAX_SESSION_TIME, JSON.stringify(idbPayload));
return userPayload;
return session;
};

View File

@ -5,7 +5,7 @@ import {IMAGES_FOLDER} from '../../R.js';
* @return {Promise<string>} Image file without extension
*/
export default () => {
return new Promise((reject, resolve)=>{
return new Promise((resolve, reject)=>{
fs.readdir(IMAGES_FOLDER, (err, files)=>{
if (err) return reject(err);
return resolve(

View File

@ -11,6 +11,7 @@ import {client} from '../../helpers/idb.js';
export default function(sessionId) {
return new Promise((resolve, reject)=>{
client.get(sessionId, (err, result)=>{
// FIXME: `result` is sometimes null on a perfect query
if (err) return reject(err);
/** @type {IDBSession} */

View File

@ -16,20 +16,53 @@ import pickRandomFile from './pickRandomFile.js';
* @param {string} filename
* @return {FileMat}
*/
function getMat(filename) {
function getMat(image) {
const mats = JSON.parse(
fs.readFileSync(
path.join(PROJECT_ROOT, 'dev', 'mat.json'), {encoding: 'utf8'}));
return mats[filename];
return mats[image];
}
/**
* Update the image mat
* @param {FileMat} imageMat
* @param {Array<number>} userMat
* @param {string} image
*/
function updateMat(imageMat, userMat, image) {
const updatedMat = Object.assign([], imageMat.mat);
for (let i = 0; i < imageMat.mat.length; i++) {
const delta = 0.1;
if (userMat[i] === 1) {
updatedMat[i] += delta;
} else {
updatedMat[i] -= delta;
}
}
console.log('OLD MAT: ', imageMat.mat);
console.log('NEW MAT: ', updatedMat);
imageMat.nums += 1;
imageMat.mat = updatedMat;
const mats = JSON.parse(
fs.readFileSync(
path.join(PROJECT_ROOT, 'dev', 'mat.json'), {encoding: 'utf8'}));
mats[image] = imageMat;
fs.writeFileSync(
path.join(PROJECT_ROOT, 'dev', 'mat.json'),
JSON.stringify(mats, null, 2), {encoding: 'utf8'});
}
/**
* @param {string} sessionId
* @param {Array<number>} mat
* @param {Array<number>} userMat
* @return {Promise}
*/
export default function(sessionId, mat) {
export default function(sessionId, userMat) {
return new Promise((resolve, reject)=>{
client.get(sessionId, (err, result)=>{
if (err) return reject(err);
@ -38,19 +71,29 @@ export default function(sessionId, mat) {
result = JSON.parse(result);
const imageMat = getMat(result.image);
const trueArgmax = argmaxThresh(imageMat.mat, 0.2).join(',');
const userArgmax = argmaxThresh(mat, 1).join(',');
const trueArgmax = argmaxThresh(imageMat.mat, 0.3).join(',');
const userArgmax = argmaxThresh(userMat, 1).join(',');
updateMat(imageMat, userMat, result.image);
console.log('trueArgmax', trueArgmax);
console.log('userArgmax', userArgmax);
if (userArgmax !== trueArgmax) {
// TODO: Decrease the score based on mat dispersion
// High variance = small reduction
// Low variance = high reduction
result.score -= 0.2;
} else {
result.score += 0.2;
}
pickRandomFile().then((image)=>{
const update = JSON.stringify(
Object.assign(result, {image: image}));
client.setex(sessionId, MAX_SESSION_TIME,
Object.assign(result, {image: image}), (err)=>{
update, (err)=>{
if (err) return reject(err);
resolve();
});

View File

@ -2,6 +2,7 @@
* User session model
* @type {Object} Session Object.
* @property {string} _sessionId The session identifier
* @property {string} _websiteKey The website id for session
*/
export class UserSession {
/**
@ -9,7 +10,6 @@ export class UserSession {
* @param {string} id
*/
set sessionId(id) {
if (id.length !== 8) throw Error('ClientID is not 8 characters');
this._sessionId = id;
}
@ -21,24 +21,40 @@ export class UserSession {
return this._sessionId;
}
/**
* Set session ID
* @param {string} id
*/
set websiteKey(id) {
this._websiteKey = id;
}
/**
* Get the session ID
* @type {string}
*/
get websiteKey() {
return this._websiteKey;
}
/**
* Serialize the session into a JSON object
* @return {Array<any>}
*/
serialize() {
const {sessionId} = this;
if (!sessionId) {
throw Error('Unable to serialize because of missing field(s)');
const {sessionId, websiteKey} = this;
if (!sessionId || !websiteKey) {
throw Error();
}
return [sessionId];
return [sessionId, websiteKey];
}
/**
* Deserialize session data from JSON object
* @param {Array<any>} payload
*/
deserialize([sessionId]) {
Object.assign(this, {sessionId});
deserialize([sessionId, websiteKey]) {
Object.assign(this, {sessionId, websiteKey});
}
};