mirror of
https://github.com/HeyPuter/puter.git
synced 2025-12-30 09:40:00 -06:00
feat: captcha
* Added Revis distributed cash to enhance our Captcha Verification system so that we prevent our system from replay attacks * Fix: There was an error with the implementation of Redis, so I reverted to our previous version that uses in memory storage * Integrated the captcha verification system into our sign in Form. The captcha verification system now works on both login and sign int * Remove test files from captcha module * Update src/backend/src/modules/captcha/middleware/captcha-middleware.js Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com> * Update src/backend/src/modules/captcha/middleware/captcha-middleware.js Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com> * Now the captcha can be requested on condition, this llaows extenstions to control wether a captcha should be required, I fixed the code in CaptchaModule to use config and got rid of the lines that made captcha middleware available since it wasn't used anywhre * I split the middleware into two distinct parts, so that the frontend can now determine captach requirements. PuterHomePageService can set GUI parameters for captcha requirements. The /whoarewe endpoint provides captcha requirement information and the extensuo system integration is maintained * Fix security issues with password handling in URL query parameters * Made sure that the enter key, submits the login request instead of refreshing the captcha * In development we can now disable the Captcha verification system by running it with CAPTCHA_ENABLED=false npm start * Went back and modified checkCaptcha so that it checks at the start to check what CAPTCHA_ENABLED is equal to * Refactor captcha system to use configuration values instead of environment variables * Fix captcha verification and align with project standards * Update src/backend/src/modules/captcha/README.md Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com> * fix: incorrect service name * dev: use Endpoint for captcha endpoints Use Endpoint class, which uses eggspress behind the scenes, which handles async errors in handlers automatically. * dev: add extension support and simplify captcha - removed extra error handling - removed dormant code - no distinction between login and signup (for now) * clean: remove local files * fix: undefined edge case --------- Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com>
This commit is contained in:
committed by
GitHub
parent
f73958ee8c
commit
ad4b3e7aeb
2
.gitignore
vendored
2
.gitignore
vendored
@@ -29,4 +29,4 @@ dist/
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
src/emulator/release/
|
||||
src/emulator/release/
|
||||
|
||||
9203
package-lock.json
generated
9203
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,7 @@
|
||||
"dependencies": {
|
||||
"@heyputer/putility": "^1.0.2",
|
||||
"dedent": "^1.5.3",
|
||||
"ioredis": "^5.6.0",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"json-colorizer": "^3.0.1",
|
||||
"open": "^10.1.0",
|
||||
|
||||
@@ -39,6 +39,7 @@ const { InternetModule } = require("./src/modules/internet/InternetModule.js");
|
||||
const { PuterExecModule } = require("./src/modules/puterexec/PuterExecModule.js");
|
||||
const { MailModule } = require("./src/modules/mail/MailModule.js");
|
||||
const { ConvertModule } = require("./src/modules/convert/ConvertModule.js");
|
||||
const { CaptchaModule } = require("./src/modules/captcha/CaptchaModule.js");
|
||||
|
||||
module.exports = {
|
||||
helloworld: () => {
|
||||
@@ -61,6 +62,7 @@ module.exports = {
|
||||
WebModule,
|
||||
TemplateModule,
|
||||
AppsModule,
|
||||
CaptchaModule,
|
||||
],
|
||||
|
||||
// Pre-built modules
|
||||
@@ -76,6 +78,7 @@ module.exports = {
|
||||
InternetModule,
|
||||
MailModule,
|
||||
ConvertModule,
|
||||
CaptchaModule,
|
||||
|
||||
// Development modules
|
||||
PerfMonModule,
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@smithy/node-http-handler": "^2.2.2",
|
||||
"args": "^5.0.3",
|
||||
"aws-sdk": "^2.1383.0",
|
||||
"axios": "^1.4.0",
|
||||
"axios": "^1.8.2",
|
||||
"bcrypt": "^5.1.0",
|
||||
"better-sqlite3": "^11.9.0",
|
||||
"busboy": "^1.6.0",
|
||||
@@ -73,6 +73,7 @@
|
||||
"ssh2": "^1.13.0",
|
||||
"string-hash": "^1.1.3",
|
||||
"string-length": "^6.0.0",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"svgo": "^3.0.2",
|
||||
"tiktoken": "^1.0.16",
|
||||
"together-ai": "^0.6.0-alpha.4",
|
||||
|
||||
@@ -483,7 +483,17 @@ module.exports = class APIError {
|
||||
'not_yet_supported': {
|
||||
status: 400,
|
||||
message: ({ message }) => message,
|
||||
}
|
||||
},
|
||||
|
||||
// Captcha errors
|
||||
'captcha_required': {
|
||||
status: 400,
|
||||
message: ({ message }) => message || 'Captcha verification required',
|
||||
},
|
||||
'captcha_invalid': {
|
||||
status: 400,
|
||||
message: ({ message }) => message || 'Invalid captcha response',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,13 @@ config.require_email_verification_to_publish_website = false;
|
||||
config.kv_max_key_size = 1024;
|
||||
config.kv_max_value_size = 400 * 1024;
|
||||
|
||||
// Captcha configuration
|
||||
config.captcha = {
|
||||
enabled: false, // Enable captcha by default
|
||||
expirationTime: 10 * 60 * 1000, // 10 minutes default expiration time
|
||||
difficulty: 'medium' // Default difficulty level
|
||||
};
|
||||
|
||||
config.monitor = {
|
||||
metricsInterval: 60000,
|
||||
windowSize: 30,
|
||||
|
||||
@@ -20,10 +20,12 @@
|
||||
|
||||
const { Kernel } = require("./Kernel");
|
||||
const CoreModule = require("./CoreModule");
|
||||
const { CaptchaModule } = require("./modules/captcha/CaptchaModule"); // Add CaptchaModule
|
||||
|
||||
const testlaunch = () => {
|
||||
const k = new Kernel();
|
||||
k.add_module(new CoreModule());
|
||||
k.add_module(new CaptchaModule()); // Register the CaptchaModule
|
||||
k.boot();
|
||||
}
|
||||
|
||||
|
||||
58
src/backend/src/modules/captcha/CaptchaModule.js
Normal file
58
src/backend/src/modules/captcha/CaptchaModule.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// METADATA // {"ai-commented":{"service":"claude"}}
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const { AdvancedBase } = require("@heyputer/putility");
|
||||
const CaptchaService = require('./services/CaptchaService');
|
||||
|
||||
/**
|
||||
* @class CaptchaModule
|
||||
* @extends AdvancedBase
|
||||
* @description Module that provides captcha verification functionality to protect
|
||||
* against automated abuse, particularly for login and signup flows. Registers
|
||||
* a CaptchaService for generating and verifying captchas as well as middlewares
|
||||
* that can be used to protect routes and determine captcha requirements.
|
||||
*/
|
||||
class CaptchaModule extends AdvancedBase {
|
||||
async install(context) {
|
||||
console.log('DIAGNOSTIC: CaptchaModule.install - Start of method');
|
||||
|
||||
// Get services from context
|
||||
const services = context.get('services');
|
||||
if (!services) {
|
||||
throw new Error('Services not available in context');
|
||||
}
|
||||
|
||||
// Register the captcha service
|
||||
console.log('DIAGNOSTIC: CaptchaModule.install - Before service registration');
|
||||
services.registerService('captcha', CaptchaService);
|
||||
console.log('DIAGNOSTIC: CaptchaModule.install - After service registration');
|
||||
|
||||
// Log the captcha service status
|
||||
try {
|
||||
const captchaService = services.get('captcha');
|
||||
console.log(`Captcha service registered and ${captchaService.enabled ? 'enabled' : 'disabled'}`);
|
||||
console.log('TOKENS_TRACKING: Retrieved CaptchaService instance with ID:', captchaService.serviceId);
|
||||
} catch (error) {
|
||||
console.error('Failed to get captcha service after registration:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CaptchaModule };
|
||||
73
src/backend/src/modules/captcha/README.md
Normal file
73
src/backend/src/modules/captcha/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Captcha Module
|
||||
|
||||
This module provides captcha verification functionality to protect against automated abuse, particularly for login and signup flows.
|
||||
|
||||
## Components
|
||||
|
||||
- **CaptchaModule.js**: Registers the service and middleware
|
||||
- **CaptchaService.js**: Provides captcha generation and verification functionality
|
||||
- **captcha-middleware.js**: Express middleware for protecting routes with captcha verification
|
||||
|
||||
## Integration
|
||||
|
||||
The CaptchaService is registered by the CaptchaModule and can be accessed by other services:
|
||||
|
||||
```javascript
|
||||
const captchaService = services.get('captcha');
|
||||
```
|
||||
|
||||
### Example Usage
|
||||
|
||||
```javascript
|
||||
// Generate a captcha
|
||||
const captcha = captchaService.generateCaptcha();
|
||||
// captcha.token - The token to verify later
|
||||
// captcha.image - SVG image data to display to the user
|
||||
|
||||
// Verify a captcha
|
||||
const isValid = captchaService.verifyCaptcha(token, userAnswer);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The CaptchaService can be configured with the following options in the configuration file (`config.json`):
|
||||
|
||||
- `captcha.enabled`: Whether the captcha service is enabled (default: false)
|
||||
- `captcha.expirationTime`: How long captcha tokens are valid in milliseconds (default: 10 minutes)
|
||||
- `captcha.difficulty`: The difficulty level of the captcha ('easy', 'medium', 'hard') (default: 'medium')
|
||||
|
||||
These options are set in the main configuration file. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"services": {
|
||||
"captcha": {
|
||||
"enabled": false,
|
||||
"expirationTime": 600000,
|
||||
"difficulty": "medium"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Development Configuration
|
||||
|
||||
For local development, you can disable captcha by creating or modifying your local configuration file (e.g., in `volatile/config/config.json` or using a profile configuration):
|
||||
|
||||
```json
|
||||
{
|
||||
"$version": "v1.1.0",
|
||||
"$requires": [
|
||||
"config.json"
|
||||
],
|
||||
"config_name": "local",
|
||||
|
||||
"services": {
|
||||
"captcha": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These options are set when registering the service in CaptchaModule.js.
|
||||
160
src/backend/src/modules/captcha/middleware/README.md
Normal file
160
src/backend/src/modules/captcha/middleware/README.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Captcha Middleware
|
||||
|
||||
This middleware provides captcha verification for routes that need protection against automated abuse.
|
||||
|
||||
## Middleware Components
|
||||
|
||||
The captcha system is now split into two middleware components:
|
||||
|
||||
1. **checkCaptcha**: Determines if captcha verification is required but doesn't perform verification.
|
||||
2. **requireCaptcha**: Performs actual captcha verification based on the result from checkCaptcha.
|
||||
|
||||
This split allows frontend applications to know in advance whether captcha verification will be needed for a particular action.
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Using Both Middlewares (Recommended)
|
||||
|
||||
For best user experience, use both middlewares together:
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Get both middleware components from the context
|
||||
const { checkCaptcha, requireCaptcha } = context.get('captcha-middleware');
|
||||
|
||||
// Determine if captcha is required for this route
|
||||
router.post('/login', checkCaptcha({ eventType: 'login' }), (req, res, next) => {
|
||||
// Set a flag in the response so frontend knows if captcha is needed
|
||||
res.locals.captchaRequired = req.captchaRequired;
|
||||
next();
|
||||
}, requireCaptcha(), (req, res) => {
|
||||
// Handle login logic
|
||||
// If captcha was required, it has been verified at this point
|
||||
});
|
||||
```
|
||||
|
||||
### Using Individual Middlewares
|
||||
|
||||
You can also access each middleware separately:
|
||||
|
||||
```javascript
|
||||
const checkCaptcha = context.get('check-captcha-middleware');
|
||||
const requireCaptcha = context.get('require-captcha-middleware');
|
||||
```
|
||||
|
||||
### Using Only requireCaptcha (Legacy Mode)
|
||||
|
||||
For backward compatibility, you can still use only the requireCaptcha middleware:
|
||||
|
||||
```javascript
|
||||
const requireCaptcha = context.get('require-captcha-middleware');
|
||||
|
||||
// Always require captcha for this route
|
||||
router.post('/sensitive-route', requireCaptcha({ always: true }), (req, res) => {
|
||||
// Route handler
|
||||
});
|
||||
|
||||
// Conditionally require captcha based on extensions
|
||||
router.post('/normal-route', requireCaptcha(), (req, res) => {
|
||||
// Route handler
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### checkCaptcha Options
|
||||
|
||||
- `always` (boolean): Always require captcha regardless of other factors
|
||||
- `strictMode` (boolean): If true, fails closed on errors (more secure)
|
||||
- `eventType` (string): Type of event for extensions (e.g., 'login', 'signup')
|
||||
|
||||
### requireCaptcha Options
|
||||
|
||||
- `strictMode` (boolean): If true, fails closed on errors (more secure)
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
There are two ways to integrate with the frontend:
|
||||
|
||||
### 1. Using the checkCaptcha Result in API Responses
|
||||
|
||||
You can include the captcha requirement in API responses:
|
||||
|
||||
```javascript
|
||||
router.get('/whoarewe', checkCaptcha({ eventType: 'login' }), (req, res) => {
|
||||
res.json({
|
||||
// Other environment information
|
||||
captchaRequired: {
|
||||
login: req.captchaRequired
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Setting GUI Parameters
|
||||
|
||||
For PuterHomepageService, you can add captcha requirements to GUI parameters:
|
||||
|
||||
```javascript
|
||||
// In PuterHomepageService.js
|
||||
gui_params: {
|
||||
// Other parameters
|
||||
captchaRequired: {
|
||||
login: req.captchaRequired
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Integration
|
||||
|
||||
To integrate with the captcha middleware, the client needs to:
|
||||
|
||||
1. Check if captcha is required for the action (using /whoarewe or GUI parameters)
|
||||
2. If required, call the `/api/captcha/generate` endpoint to get a captcha token and image
|
||||
3. Display the captcha image to the user and collect their answer
|
||||
4. Include the captcha token and answer in the request body:
|
||||
|
||||
```javascript
|
||||
// Example client-side code
|
||||
async function submitWithCaptcha(formData) {
|
||||
// Check if captcha is required
|
||||
const envInfo = await fetch('/api/whoarewe').then(r => r.json());
|
||||
|
||||
if (envInfo.captchaRequired?.login) {
|
||||
// Get and display captcha to user
|
||||
const captcha = await getCaptchaFromServer();
|
||||
showCaptchaToUser(captcha);
|
||||
|
||||
// Add captcha token and answer to the form data
|
||||
formData.captchaToken = captcha.token;
|
||||
formData.captchaAnswer = await getUserCaptchaAnswer();
|
||||
}
|
||||
|
||||
// Submit the form
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
// Handle response
|
||||
const data = await response.json();
|
||||
if (response.status === 400 && data.error === 'captcha_required') {
|
||||
// Show captcha to the user if not already shown
|
||||
showCaptcha();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The middleware will throw the following errors:
|
||||
|
||||
- `captcha_required`: When captcha verification is required but no token or answer was provided.
|
||||
- `captcha_invalid`: When the provided captcha answer is incorrect.
|
||||
|
||||
These errors can be caught by the API error handler and returned to the client.
|
||||
134
src/backend/src/modules/captcha/middleware/captcha-middleware.js
Normal file
134
src/backend/src/modules/captcha/middleware/captcha-middleware.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const APIError = require("../../../api/APIError");
|
||||
const { Context } = require("../../../util/context");
|
||||
|
||||
/**
|
||||
* Middleware that checks if captcha verification is required
|
||||
* This is the "first half" of the captcha verification process
|
||||
* It determines if verification is needed but doesn't perform verification
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} [options.strictMode=true] - If true, fails closed on errors (more secure)
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
const checkCaptcha = ({ svc_captcha }) => async (req, res, next) => {
|
||||
// Get services from the Context
|
||||
const services = Context.get('services');
|
||||
|
||||
if ( ! svc_captcha.enabled ) {
|
||||
req.captchaRequired = false;
|
||||
return next();
|
||||
}
|
||||
|
||||
const svc_event = services.get('event');
|
||||
const event = {
|
||||
// By default, captcha always appears if enabled
|
||||
required: true,
|
||||
};
|
||||
await svc_event.emit('captcha.check', event);
|
||||
|
||||
// Set captcha requirement based on service status
|
||||
req.captchaRequired = event.required;
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware that requires captcha verification
|
||||
* This is the "second half" of the captcha verification process
|
||||
* It uses the result from checkCaptcha to determine if verification is needed
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} [options.strictMode=true] - If true, fails closed on errors (more secure)
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
const requireCaptcha = (options = {}) => async (req, res, next) => {
|
||||
if ( ! req.captchaRequired ) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const services = Context.get('services');
|
||||
|
||||
try {
|
||||
let captchaService;
|
||||
try {
|
||||
captchaService = services.get('captcha');
|
||||
} catch (error) {
|
||||
console.warn('Captcha verification: required service not available', error);
|
||||
return next(APIError.create('internal_error', null, {
|
||||
message: 'Captcha service unavailable',
|
||||
status: 503
|
||||
}));
|
||||
}
|
||||
|
||||
// Fail closed if captcha service doesn't exist or isn't properly initialized
|
||||
if (!captchaService || typeof captchaService.verifyCaptcha !== 'function') {
|
||||
return next(APIError.create('internal_error', null, {
|
||||
message: 'Captcha service misconfigured',
|
||||
status: 500
|
||||
}));
|
||||
}
|
||||
|
||||
// Check for captcha token and answer in request
|
||||
const captchaToken = req.body.captchaToken;
|
||||
const captchaAnswer = req.body.captchaAnswer;
|
||||
|
||||
if (!captchaToken || !captchaAnswer) {
|
||||
return next(APIError.create('captcha_required', null, {
|
||||
message: 'Captcha verification required',
|
||||
status: 400
|
||||
}));
|
||||
}
|
||||
|
||||
// Verify the captcha
|
||||
let isValid;
|
||||
try {
|
||||
isValid = captchaService.verifyCaptcha(captchaToken, captchaAnswer);
|
||||
} catch (verifyError) {
|
||||
console.error('Captcha verification: threw an error', verifyError);
|
||||
return next(APIError.create('captcha_invalid', null, {
|
||||
message: 'Captcha verification failed',
|
||||
status: 400
|
||||
}));
|
||||
}
|
||||
|
||||
// Check verification result
|
||||
if (!isValid) {
|
||||
return next(APIError.create('captcha_invalid', null, {
|
||||
message: 'Invalid captcha response',
|
||||
status: 400
|
||||
}));
|
||||
}
|
||||
|
||||
// Captcha verified successfully, continue
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Captcha verification: unexpected error', error);
|
||||
return next(APIError.create('internal_error', null, {
|
||||
message: 'Captcha verification failed',
|
||||
status: 500
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
checkCaptcha,
|
||||
requireCaptcha
|
||||
};
|
||||
674
src/backend/src/modules/captcha/services/CaptchaService.js
Normal file
674
src/backend/src/modules/captcha/services/CaptchaService.js
Normal file
@@ -0,0 +1,674 @@
|
||||
// METADATA // {"ai-commented":{"service":"claude"}}
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const BaseService = require('../../../services/BaseService');
|
||||
const { Endpoint } = require('../../../util/expressutil');
|
||||
const { checkCaptcha } = require('../middleware/captcha-middleware');
|
||||
|
||||
/**
|
||||
* @class CaptchaService
|
||||
* @extends BaseService
|
||||
* @description Service that provides captcha generation and verification functionality
|
||||
* to protect against automated abuse. Uses svg-captcha for generation and maintains
|
||||
* a token-based verification system.
|
||||
*/
|
||||
class CaptchaService extends BaseService {
|
||||
/**
|
||||
* Initializes the captcha service with configuration and storage
|
||||
*/
|
||||
async _construct() {
|
||||
console.log('DIAGNOSTIC: CaptchaService._construct called');
|
||||
|
||||
// Load dependencies
|
||||
this.crypto = require('crypto');
|
||||
this.svgCaptcha = require('svg-captcha');
|
||||
|
||||
// In-memory token storage with expiration
|
||||
this.captchaTokens = new Map();
|
||||
|
||||
// Service instance diagnostic tracking
|
||||
this.serviceId = Math.random().toString(36).substring(2, 10);
|
||||
this.requestCounter = 0;
|
||||
|
||||
console.log('TOKENS_TRACKING: CaptchaService instance created with ID:', this.serviceId);
|
||||
console.log('TOKENS_TRACKING: Process ID:', process.pid);
|
||||
|
||||
// Get configuration from service config
|
||||
this.enabled = this.config.enabled === true;
|
||||
this.expirationTime = this.config.expirationTime || (10 * 60 * 1000); // 10 minutes default
|
||||
this.difficulty = this.config.difficulty || 'medium';
|
||||
this.testMode = this.config.testMode === true;
|
||||
|
||||
console.log('CAPTCHA DIAGNOSTIC: Service initialized with config:', {
|
||||
enabled: this.enabled,
|
||||
expirationTime: this.expirationTime,
|
||||
difficulty: this.difficulty,
|
||||
testMode: this.testMode
|
||||
});
|
||||
|
||||
// Add a static test token for diagnostic purposes
|
||||
this.captchaTokens.set('test-static-token', {
|
||||
text: 'testanswer',
|
||||
expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year
|
||||
});
|
||||
|
||||
// Flag to track if endpoints are registered
|
||||
this.endpointsRegistered = false;
|
||||
}
|
||||
|
||||
async ['__on_install.middlewares.context-aware'] (_, { app }) {
|
||||
// Add express middleware
|
||||
app.use(checkCaptcha({ svc_captcha: this }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up API endpoints and cleanup tasks
|
||||
*/
|
||||
async _init() {
|
||||
console.log('TOKENS_TRACKING: CaptchaService._init called. Service ID:', this.serviceId);
|
||||
|
||||
if (!this.enabled) {
|
||||
this.log.info('Captcha service is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up periodic cleanup
|
||||
this.cleanupInterval = setInterval(() => this.cleanupExpiredTokens(), 15 * 60 * 1000);
|
||||
|
||||
// Register endpoints if not already done
|
||||
if (!this.endpointsRegistered) {
|
||||
this.registerEndpoints();
|
||||
this.endpointsRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup method called when service is being destroyed
|
||||
*/
|
||||
async _destroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
this.captchaTokens.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the captcha API endpoints with the web service
|
||||
* @private
|
||||
*/
|
||||
registerEndpoints() {
|
||||
if (this.endpointsRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get the web service
|
||||
let webService = null;
|
||||
try {
|
||||
webService = this.services.get('web-service');
|
||||
} catch (error) {
|
||||
// Web service not available, try web-server
|
||||
try {
|
||||
webService = this.services.get('web-server');
|
||||
} catch (innerError) {
|
||||
this.log.warn('Neither web-service nor web-server are available yet');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!webService || !webService.app) {
|
||||
this.log.warn('Web service found but app is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const app = webService.app;
|
||||
|
||||
const api = this.require('express').Router();
|
||||
app.use('/api/captcha', api);
|
||||
|
||||
// Generate captcha endpoint
|
||||
Endpoint({
|
||||
route: '/generate',
|
||||
methods: ['GET'],
|
||||
handler: async (req, res) => {
|
||||
const captcha = this.generateCaptcha();
|
||||
res.json({
|
||||
token: captcha.token,
|
||||
image: captcha.data
|
||||
});
|
||||
},
|
||||
}).attach(api);
|
||||
|
||||
// Verify captcha endpoint
|
||||
Endpoint({
|
||||
route: '/verify',
|
||||
methods: ['POST'],
|
||||
handler: (req, res) => {
|
||||
const { token, answer } = req.body;
|
||||
|
||||
if (!token || !answer) {
|
||||
return res.status(400).json({
|
||||
valid: false,
|
||||
error: 'Missing token or answer'
|
||||
});
|
||||
}
|
||||
|
||||
const isValid = this.verifyCaptcha(token, answer);
|
||||
res.json({ valid: isValid });
|
||||
},
|
||||
}).attach(api);
|
||||
|
||||
// Special endpoint for automated testing
|
||||
// This should be disabled in production
|
||||
if (this.testMode) {
|
||||
app.post('/api/captcha/create-test-token', (req, res) => {
|
||||
try {
|
||||
const { token, answer } = req.body;
|
||||
|
||||
if (!token || !answer) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing token or answer'
|
||||
});
|
||||
}
|
||||
|
||||
// Store the test token with the provided answer
|
||||
this.captchaTokens.set(token, {
|
||||
text: answer.toLowerCase(),
|
||||
expiresAt: Date.now() + this.expirationTime
|
||||
});
|
||||
|
||||
this.log.debug(`Created test token: ${token} with answer: ${answer}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
this.log.error(`Error creating test token: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to create test token' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Diagnostic endpoint - should be used carefully and only during debugging
|
||||
app.get('/api/captcha/diagnostic', (req, res) => {
|
||||
try {
|
||||
// Get information about the current state
|
||||
const diagnosticInfo = {
|
||||
serviceEnabled: this.enabled,
|
||||
difficulty: this.difficulty,
|
||||
expirationTime: this.expirationTime,
|
||||
testMode: this.testMode,
|
||||
activeTokenCount: this.captchaTokens.size,
|
||||
serviceId: this.serviceId,
|
||||
processId: process.pid,
|
||||
requestCounter: this.requestCounter,
|
||||
hasStaticTestToken: this.captchaTokens.has('test-static-token'),
|
||||
tokensState: Array.from(this.captchaTokens).map(([token, data]) => ({
|
||||
tokenPrefix: token.substring(0, 8) + '...',
|
||||
expiresAt: new Date(data.expiresAt).toISOString(),
|
||||
expired: data.expiresAt < Date.now(),
|
||||
expectedAnswer: data.text
|
||||
}))
|
||||
};
|
||||
|
||||
res.json(diagnosticInfo);
|
||||
} catch (error) {
|
||||
this.log.error(`Error in diagnostic endpoint: ${error.message}`);
|
||||
res.status(500).json({ error: 'Diagnostic error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Advanced token debugging endpoint - allows testing
|
||||
app.get('/api/captcha/debug-tokens', (req, res) => {
|
||||
try {
|
||||
// Check if we're the same service instance
|
||||
const currentTimestamp = Date.now();
|
||||
const currentTokens = Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8));
|
||||
|
||||
// Create a test token that won't expire soon
|
||||
const debugToken = 'debug-' + this.crypto.randomBytes(8).toString('hex');
|
||||
const debugAnswer = 'test123';
|
||||
|
||||
this.captchaTokens.set(debugToken, {
|
||||
text: debugAnswer,
|
||||
expiresAt: currentTimestamp + (60 * 60 * 1000) // 1 hour
|
||||
});
|
||||
|
||||
// Information about the current service instance
|
||||
const serviceInfo = {
|
||||
message: 'Debug token created - use for testing captcha validation',
|
||||
serviceId: this.serviceId,
|
||||
debugToken: debugToken,
|
||||
debugAnswer: debugAnswer,
|
||||
tokensBefore: currentTokens,
|
||||
tokensAfter: Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8)),
|
||||
currentTokenCount: this.captchaTokens.size,
|
||||
timestamp: currentTimestamp,
|
||||
processId: process.pid
|
||||
};
|
||||
|
||||
res.json(serviceInfo);
|
||||
} catch (error) {
|
||||
this.log.error(`Error in debug-tokens endpoint: ${error.message}`);
|
||||
res.status(500).json({ error: 'Debug token creation error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Configuration verification endpoint
|
||||
app.get('/api/captcha/config-status', (req, res) => {
|
||||
try {
|
||||
// Information about configuration states
|
||||
const configInfo = {
|
||||
serviceEnabled: this.enabled,
|
||||
serviceDifficulty: this.difficulty,
|
||||
configSource: 'Service configuration',
|
||||
centralConfig: {
|
||||
enabled: this.enabled,
|
||||
difficulty: this.difficulty,
|
||||
expirationTime: this.expirationTime,
|
||||
testMode: this.testMode
|
||||
},
|
||||
usingCentralizedConfig: true,
|
||||
configConsistency: this.enabled === (this.enabled === true),
|
||||
serviceId: this.serviceId,
|
||||
processId: process.pid
|
||||
};
|
||||
|
||||
res.json(configInfo);
|
||||
} catch (error) {
|
||||
this.log.error(`Error in config-status endpoint: ${error.message}`);
|
||||
res.status(500).json({ error: 'Configuration status error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Test endpoint to validate token lifecycle
|
||||
app.get('/api/captcha/test-lifecycle', (req, res) => {
|
||||
try {
|
||||
console.log('TOKENS_TRACKING: Running token lifecycle test. Service ID:', this.serviceId);
|
||||
console.log('TOKENS_TRACKING: Initial token count:', this.captchaTokens.size);
|
||||
|
||||
// Create a test captcha
|
||||
const testText = 'test123';
|
||||
const testToken = 'lifecycle-' + this.crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Store the test token
|
||||
this.captchaTokens.set(testToken, {
|
||||
text: testText,
|
||||
expiresAt: Date.now() + this.expirationTime
|
||||
});
|
||||
|
||||
console.log('TOKENS_TRACKING: Test token stored. New token count:', this.captchaTokens.size);
|
||||
|
||||
// Verify the token exists
|
||||
const tokenExists = this.captchaTokens.has(testToken);
|
||||
console.log('TOKENS_TRACKING: Test token exists in map:', tokenExists);
|
||||
|
||||
// Try to verify with correct answer
|
||||
const correctVerification = this.verifyCaptcha(testToken, testText);
|
||||
console.log('TOKENS_TRACKING: Verification with correct answer result:', correctVerification);
|
||||
|
||||
// Check if token was deleted after verification
|
||||
const tokenAfterVerification = this.captchaTokens.has(testToken);
|
||||
console.log('TOKENS_TRACKING: Token exists after verification:', tokenAfterVerification);
|
||||
|
||||
// Create another test token
|
||||
const testToken2 = 'lifecycle2-' + this.crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Store the test token
|
||||
this.captchaTokens.set(testToken2, {
|
||||
text: testText,
|
||||
expiresAt: Date.now() + this.expirationTime
|
||||
});
|
||||
|
||||
console.log('TOKENS_TRACKING: Second test token stored. Token count:', this.captchaTokens.size);
|
||||
|
||||
res.json({
|
||||
message: 'Token lifecycle test completed',
|
||||
serviceId: this.serviceId,
|
||||
initialTokens: this.captchaTokens.size - 2, // minus the two we added
|
||||
tokenCreated: true,
|
||||
tokenExisted: tokenExists,
|
||||
verificationResult: correctVerification,
|
||||
tokenRemovedAfterVerification: !tokenAfterVerification,
|
||||
secondTokenCreated: this.captchaTokens.has(testToken2),
|
||||
processId: process.pid
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('TOKENS_TRACKING: Error in test-lifecycle endpoint:', error);
|
||||
res.status(500).json({ error: 'Test lifecycle error' });
|
||||
}
|
||||
});
|
||||
|
||||
this.endpointsRegistered = true;
|
||||
this.log.info('Captcha service endpoints registered successfully');
|
||||
|
||||
// Emit an event that captcha service is ready
|
||||
try {
|
||||
const eventService = this.services.get('event');
|
||||
if (eventService) {
|
||||
eventService.emit('service-ready', 'captcha');
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors with event service
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.warn(`Could not register captcha endpoints: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new captcha with a unique token
|
||||
* @returns {Object} Object containing token and SVG image
|
||||
*/
|
||||
generateCaptcha() {
|
||||
console.log('====== CAPTCHA GENERATION DIAGNOSTIC ======');
|
||||
console.log('TOKENS_TRACKING: generateCaptcha called. Service ID:', this.serviceId);
|
||||
console.log('TOKENS_TRACKING: Token map size before generation:', this.captchaTokens.size);
|
||||
console.log('TOKENS_TRACKING: Static test token exists:', this.captchaTokens.has('test-static-token'));
|
||||
|
||||
// Increment request counter for diagnostics
|
||||
this.requestCounter++;
|
||||
console.log('TOKENS_TRACKING: Request counter value:', this.requestCounter);
|
||||
|
||||
console.log('generateCaptcha called, service enabled:', this.enabled);
|
||||
|
||||
if (!this.enabled) {
|
||||
console.log('Generation SKIPPED: Captcha service is disabled');
|
||||
throw new Error('Captcha service is disabled');
|
||||
}
|
||||
|
||||
// Configure captcha options based on difficulty
|
||||
const options = this._getCaptchaOptions();
|
||||
console.log('Using captcha options for difficulty:', this.difficulty);
|
||||
|
||||
// Generate the captcha
|
||||
const captcha = this.svgCaptcha.create(options);
|
||||
console.log('Captcha created with text:', captcha.text);
|
||||
|
||||
// Generate a unique token
|
||||
const token = this.crypto.randomBytes(32).toString('hex');
|
||||
console.log('Generated token:', token.substring(0, 8) + '...');
|
||||
|
||||
// Store token with captcha text and expiration
|
||||
const expirationTime = Date.now() + this.expirationTime;
|
||||
console.log('Token will expire at:', new Date(expirationTime));
|
||||
|
||||
console.log('TOKENS_TRACKING: Token map size before storing new token:', this.captchaTokens.size);
|
||||
|
||||
this.captchaTokens.set(token, {
|
||||
text: captcha.text.toLowerCase(),
|
||||
expiresAt: expirationTime
|
||||
});
|
||||
|
||||
console.log('TOKENS_TRACKING: Token map size after storing new token:', this.captchaTokens.size);
|
||||
console.log('Token stored in captchaTokens. Current token count:', this.captchaTokens.size);
|
||||
this.log.debug(`Generated captcha with token: ${token}`);
|
||||
|
||||
return {
|
||||
token: token,
|
||||
data: captcha.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a captcha answer against a stored token
|
||||
* @param {string} token - The captcha token
|
||||
* @param {string} userAnswer - The user's answer to verify
|
||||
* @returns {boolean} Whether the answer is valid
|
||||
*/
|
||||
verifyCaptcha(token, userAnswer) {
|
||||
console.log('====== CAPTCHA SERVICE VERIFICATION DIAGNOSTIC ======');
|
||||
console.log('TOKENS_TRACKING: verifyCaptcha called. Service ID:', this.serviceId);
|
||||
console.log('TOKENS_TRACKING: Request counter during verification:', this.requestCounter);
|
||||
console.log('TOKENS_TRACKING: Static test token exists:', this.captchaTokens.has('test-static-token'));
|
||||
console.log('TOKENS_TRACKING: Trying to verify token:', token ? token.substring(0, 8) + '...' : 'undefined');
|
||||
|
||||
console.log('verifyCaptcha called with token:', token ? token.substring(0, 8) + '...' : 'undefined');
|
||||
console.log('userAnswer:', userAnswer);
|
||||
console.log('Service enabled:', this.enabled);
|
||||
console.log('Number of tokens in captchaTokens:', this.captchaTokens.size);
|
||||
|
||||
// Service health check
|
||||
this._checkServiceHealth();
|
||||
|
||||
if (!this.enabled) {
|
||||
console.log('Verification SKIPPED: Captcha service is disabled');
|
||||
this.log.warn('Captcha verification attempted while service is disabled');
|
||||
throw new Error('Captcha service is disabled');
|
||||
}
|
||||
|
||||
// Get captcha data for token
|
||||
const captchaData = this.captchaTokens.get(token);
|
||||
console.log('Captcha data found for token:', !!captchaData);
|
||||
|
||||
// Invalid token or expired
|
||||
if (!captchaData) {
|
||||
console.log('Verification FAILED: No data found for this token');
|
||||
console.log('TOKENS_TRACKING: Available tokens (first 8 chars):',
|
||||
Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8)));
|
||||
this.log.debug(`Invalid captcha token: ${token}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (captchaData.expiresAt < Date.now()) {
|
||||
console.log('Verification FAILED: Token expired at:', new Date(captchaData.expiresAt));
|
||||
this.log.debug(`Expired captcha token: ${token}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize and compare answers
|
||||
const normalizedUserAnswer = userAnswer.toLowerCase().trim();
|
||||
console.log('Expected answer:', captchaData.text);
|
||||
console.log('User answer (normalized):', normalizedUserAnswer);
|
||||
const isValid = captchaData.text === normalizedUserAnswer;
|
||||
console.log('Answer comparison result:', isValid);
|
||||
|
||||
// Remove token after verification (one-time use)
|
||||
this.captchaTokens.delete(token);
|
||||
console.log('Token removed after verification (one-time use)');
|
||||
console.log('TOKENS_TRACKING: Token map size after removing used token:', this.captchaTokens.size);
|
||||
|
||||
this.log.debug(`Verified captcha token: ${token}, valid: ${isValid}`);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple diagnostic method to check service health
|
||||
* @private
|
||||
*/
|
||||
_checkServiceHealth() {
|
||||
console.log('TOKENS_TRACKING: Service health check. ID:', this.serviceId, 'Token count:', this.captchaTokens.size);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes expired captcha tokens from memory
|
||||
*/
|
||||
cleanupExpiredTokens() {
|
||||
console.log('TOKENS_TRACKING: Running token cleanup. Service ID:', this.serviceId);
|
||||
console.log('TOKENS_TRACKING: Token map size before cleanup:', this.captchaTokens.size);
|
||||
|
||||
const now = Date.now();
|
||||
let expiredCount = 0;
|
||||
let validCount = 0;
|
||||
|
||||
// Log all tokens before cleanup
|
||||
console.log('TOKENS_TRACKING: Current tokens before cleanup:');
|
||||
for (const [token, data] of this.captchaTokens.entries()) {
|
||||
const isExpired = data.expiresAt < now;
|
||||
console.log(`TOKENS_TRACKING: Token ${token.substring(0, 8)}... expires: ${new Date(data.expiresAt).toISOString()}, expired: ${isExpired}`);
|
||||
|
||||
if (isExpired) {
|
||||
expiredCount++;
|
||||
} else {
|
||||
validCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Only do the actual cleanup if we found expired tokens
|
||||
if (expiredCount > 0) {
|
||||
console.log(`TOKENS_TRACKING: Found ${expiredCount} expired tokens to remove and ${validCount} valid tokens to keep`);
|
||||
|
||||
// Clean up expired tokens
|
||||
for (const [token, data] of this.captchaTokens.entries()) {
|
||||
if (data.expiresAt < now) {
|
||||
this.captchaTokens.delete(token);
|
||||
console.log(`TOKENS_TRACKING: Deleted expired token: ${token.substring(0, 8)}...`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('TOKENS_TRACKING: No expired tokens found, skipping cleanup');
|
||||
}
|
||||
|
||||
// Skip cleanup for the static test token
|
||||
if (this.captchaTokens.has('test-static-token')) {
|
||||
console.log('TOKENS_TRACKING: Static test token still exists after cleanup');
|
||||
} else {
|
||||
console.log('TOKENS_TRACKING: WARNING - Static test token was removed during cleanup');
|
||||
|
||||
// Restore the static test token for diagnostic purposes
|
||||
this.captchaTokens.set('test-static-token', {
|
||||
text: 'testanswer',
|
||||
expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year
|
||||
});
|
||||
console.log('TOKENS_TRACKING: Restored static test token');
|
||||
}
|
||||
|
||||
console.log('TOKENS_TRACKING: Token map size after cleanup:', this.captchaTokens.size);
|
||||
|
||||
if (expiredCount > 0) {
|
||||
this.log.debug(`Cleaned up ${expiredCount} expired captcha tokens`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets captcha options based on the configured difficulty
|
||||
* @private
|
||||
* @returns {Object} Captcha configuration options
|
||||
*/
|
||||
_getCaptchaOptions() {
|
||||
const baseOptions = {
|
||||
size: 6, // Default captcha length
|
||||
ignoreChars: '0o1ilI', // Characters to avoid (confusing)
|
||||
noise: 2, // Lines to add as noise
|
||||
color: true,
|
||||
background: '#f0f0f0'
|
||||
};
|
||||
|
||||
switch (this.difficulty) {
|
||||
case 'easy':
|
||||
return {
|
||||
...baseOptions,
|
||||
size: 4,
|
||||
width: 150,
|
||||
height: 50,
|
||||
noise: 1
|
||||
};
|
||||
case 'hard':
|
||||
return {
|
||||
...baseOptions,
|
||||
size: 7,
|
||||
width: 200,
|
||||
height: 60,
|
||||
noise: 3
|
||||
};
|
||||
case 'medium':
|
||||
default:
|
||||
return {
|
||||
...baseOptions,
|
||||
width: 180,
|
||||
height: 50
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the captcha service is properly configured and working
|
||||
* This is used during initialization and can be called to check system status
|
||||
* @returns {boolean} Whether the service is properly configured and functioning
|
||||
*/
|
||||
verifySelfTest() {
|
||||
try {
|
||||
// Ensure required dependencies are available
|
||||
if (!this.svgCaptcha) {
|
||||
this.log.error('Captcha service self-test failed: svg-captcha module not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.enabled) {
|
||||
this.log.warn('Captcha service self-test failed: service is disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if (!this.expirationTime || typeof this.expirationTime !== 'number') {
|
||||
this.log.error('Captcha service self-test failed: invalid expiration time configuration');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic functionality test - generate a test captcha and verify storage
|
||||
const testToken = 'test-' + this.crypto.randomBytes(8).toString('hex');
|
||||
const testText = 'testcaptcha';
|
||||
|
||||
// Store the test captcha
|
||||
this.captchaTokens.set(testToken, {
|
||||
text: testText,
|
||||
expiresAt: Date.now() + this.expirationTime
|
||||
});
|
||||
|
||||
// Verify the test captcha
|
||||
const correctVerification = this.verifyCaptcha(testToken, testText);
|
||||
|
||||
// Check if verification worked and token was removed
|
||||
if (!correctVerification || this.captchaTokens.has(testToken)) {
|
||||
this.log.error('Captcha service self-test failed: verification test failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.log.debug('Captcha service self-test passed');
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log.error(`Captcha service self-test failed with error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the service's diagnostic information
|
||||
* @returns {Object} Diagnostic information about the service
|
||||
*/
|
||||
getDiagnosticInfo() {
|
||||
return {
|
||||
serviceId: this.serviceId,
|
||||
enabled: this.enabled,
|
||||
tokenCount: this.captchaTokens.size,
|
||||
requestCounter: this.requestCounter,
|
||||
config: {
|
||||
enabled: this.enabled,
|
||||
difficulty: this.difficulty,
|
||||
expirationTime: this.expirationTime,
|
||||
testMode: this.testMode
|
||||
},
|
||||
processId: process.pid,
|
||||
testTokenExists: this.captchaTokens.has('test-static-token')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export both as a named export and as a default export for compatibility
|
||||
module.exports = CaptchaService;
|
||||
module.exports.CaptchaService = CaptchaService;
|
||||
@@ -22,6 +22,7 @@ const router = new express.Router();
|
||||
const { get_user, body_parser_error_handler } = require('../helpers');
|
||||
const config = require('../config');
|
||||
const { DB_WRITE } = require('../services/database/consts');
|
||||
const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middleware');
|
||||
|
||||
|
||||
const complete_ = async ({ req, res, user }) => {
|
||||
@@ -55,7 +56,20 @@ const complete_ = async ({ req, res, user }) => {
|
||||
// -----------------------------------------------------------------------//
|
||||
// POST /file
|
||||
// -----------------------------------------------------------------------//
|
||||
router.post('/login', express.json(), body_parser_error_handler, async (req, res, next)=>{
|
||||
router.post('/login', express.json(), body_parser_error_handler,
|
||||
// Add diagnostic middleware to log captcha data
|
||||
(req, res, next) => {
|
||||
console.log('====== LOGIN CAPTCHA DIAGNOSTIC ======');
|
||||
console.log('LOGIN REQUEST RECEIVED with captcha data:', {
|
||||
hasCaptchaToken: !!req.body.captchaToken,
|
||||
hasCaptchaAnswer: !!req.body.captchaAnswer,
|
||||
captchaToken: req.body.captchaToken ? req.body.captchaToken.substring(0, 8) + '...' : undefined,
|
||||
captchaAnswer: req.body.captchaAnswer
|
||||
});
|
||||
next();
|
||||
},
|
||||
requireCaptcha({ strictMode: true, eventType: 'login' }),
|
||||
async (req, res, next)=>{
|
||||
// either api. subdomain or no subdomain
|
||||
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
|
||||
next();
|
||||
@@ -151,7 +165,7 @@ router.post('/login', express.json(), body_parser_error_handler, async (req, res
|
||||
|
||||
})
|
||||
|
||||
router.post('/login/otp', express.json(), body_parser_error_handler, async (req, res, next) => {
|
||||
router.post('/login/otp', express.json(), body_parser_error_handler, requireCaptcha({ strictMode: true, eventType: 'login_otp' }), async (req, res, next) => {
|
||||
// either api. subdomain or no subdomain
|
||||
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
|
||||
next();
|
||||
@@ -207,7 +221,7 @@ router.post('/login/otp', express.json(), body_parser_error_handler, async (req,
|
||||
return await complete_({ req, res, user });
|
||||
});
|
||||
|
||||
router.post('/login/recovery-code', express.json(), body_parser_error_handler, async (req, res, next) => {
|
||||
router.post('/login/recovery-code', express.json(), body_parser_error_handler, requireCaptcha({ strictMode: true, eventType: 'login_recovery' }), async (req, res, next) => {
|
||||
// either api. subdomain or no subdomain
|
||||
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
|
||||
next();
|
||||
|
||||
@@ -25,6 +25,7 @@ const { DB_WRITE } = require('../services/database/consts');
|
||||
const { generate_identifier } = require('../util/identifier');
|
||||
const { is_temp_users_disabled: lazy_temp_users,
|
||||
is_user_signup_disabled: lazy_user_signup } = require("../helpers")
|
||||
const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middleware');
|
||||
|
||||
async function generate_random_username () {
|
||||
let username;
|
||||
@@ -48,6 +49,7 @@ module.exports = eggspress(['/signup'], {
|
||||
res.status(400).send(`email username mismatch; please provide a password`);
|
||||
}
|
||||
},
|
||||
mw: [requireCaptcha({ strictMode: true, eventType: 'signup' })], // Conditionally require captcha for signup
|
||||
}, async (req, res, next) => {
|
||||
// either api. subdomain or no subdomain
|
||||
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
|
||||
|
||||
@@ -21,6 +21,7 @@ const { PathBuilder } = require("../util/pathutil");
|
||||
const BaseService = require("./BaseService");
|
||||
const {is_valid_url} = require('../helpers');
|
||||
const { Endpoint } = require("../util/expressutil");
|
||||
const { Context } = require("../util/context");
|
||||
|
||||
/**
|
||||
* PuterHomepageService serves the initial HTML page that loads the Puter GUI
|
||||
@@ -72,10 +73,23 @@ class PuterHomepageService extends BaseService {
|
||||
route: '/whoarewe',
|
||||
methods: ['GET'],
|
||||
handler: async (req, res) => {
|
||||
res.json({
|
||||
// Get basic configuration information
|
||||
const responseData = {
|
||||
disable_user_signup: this.global_config.disable_user_signup,
|
||||
disable_temp_users: this.global_config.disable_temp_users,
|
||||
});
|
||||
environmentInfo: {
|
||||
env: this.global_config.env,
|
||||
version: process.env.VERSION || 'development'
|
||||
}
|
||||
};
|
||||
|
||||
// Add captcha requirement information
|
||||
responseData.captchaRequired = {
|
||||
login: req.captchaRequired,
|
||||
signup: req.captchaRequired,
|
||||
};
|
||||
|
||||
res.json(responseData);
|
||||
}
|
||||
}).attach(app);
|
||||
}
|
||||
@@ -106,6 +120,12 @@ class PuterHomepageService extends BaseService {
|
||||
}));
|
||||
}
|
||||
|
||||
// checkCaptcha middleware (in CaptchaService) sets req.captchaRequired
|
||||
const captchaRequired = {
|
||||
login: req.captchaRequired,
|
||||
signup: req.captchaRequired,
|
||||
};
|
||||
|
||||
return res.send(this.generate_puter_page_html({
|
||||
env: config.env,
|
||||
|
||||
@@ -144,6 +164,8 @@ class PuterHomepageService extends BaseService {
|
||||
long_description: config.long_description,
|
||||
disable_temp_users: config.disable_temp_users,
|
||||
co_isolation_enabled: req.co_isolation_enabled,
|
||||
// Add captcha requirements to GUI parameters
|
||||
captchaRequired: captchaRequired,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require('mocha');
|
||||
const { expect } = require('chai');
|
||||
const sinon = require('sinon');
|
||||
|
||||
// Mock the Context and services
|
||||
const Context = {
|
||||
get: sinon.stub()
|
||||
};
|
||||
|
||||
// Mock the extension service
|
||||
class ExtensionService {
|
||||
constructor() {
|
||||
this.extensions = new Map();
|
||||
this.eventHandlers = new Map();
|
||||
}
|
||||
|
||||
registerExtension(name, extension) {
|
||||
this.extensions.set(name, extension);
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
if (!this.eventHandlers.has(event)) {
|
||||
this.eventHandlers.set(event, []);
|
||||
}
|
||||
this.eventHandlers.get(event).push(handler);
|
||||
}
|
||||
|
||||
async emit(event, data) {
|
||||
const handlers = this.eventHandlers.get(event) || [];
|
||||
for (const handler of handlers) {
|
||||
await handler(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Extension Integration with Captcha', () => {
|
||||
let extensionService, captchaService, services;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset stubs
|
||||
sinon.reset();
|
||||
|
||||
// Create fresh instances
|
||||
extensionService = new ExtensionService();
|
||||
captchaService = {
|
||||
enabled: true,
|
||||
verifyCaptcha: sinon.stub()
|
||||
};
|
||||
|
||||
services = {
|
||||
get: sinon.stub()
|
||||
};
|
||||
|
||||
// Configure service mocks
|
||||
services.get.withArgs('extension').returns(extensionService);
|
||||
services.get.withArgs('captcha').returns(captchaService);
|
||||
|
||||
// Configure Context mock
|
||||
Context.get.withArgs('services').returns(services);
|
||||
});
|
||||
|
||||
describe('Extension Event Handling', () => {
|
||||
it('should allow extensions to require captcha via event handler', async () => {
|
||||
// Setup - create a test extension that requires captcha
|
||||
const testExtension = {
|
||||
name: 'test-extension',
|
||||
onCaptchaValidate: async (event) => {
|
||||
if (event.type === 'login' && event.ip === '1.2.3.4') {
|
||||
event.require = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Register extension and event handler
|
||||
extensionService.registerExtension(testExtension.name, testExtension);
|
||||
extensionService.on('captcha.validate', testExtension.onCaptchaValidate);
|
||||
|
||||
// Test event emission
|
||||
const eventData = {
|
||||
type: 'login',
|
||||
ip: '1.2.3.4',
|
||||
require: false
|
||||
};
|
||||
|
||||
await extensionService.emit('captcha.validate', eventData);
|
||||
|
||||
// Assert
|
||||
expect(eventData.require).to.be.true;
|
||||
});
|
||||
|
||||
it('should allow extensions to disable captcha requirement', async () => {
|
||||
// Setup - create a test extension that disables captcha
|
||||
const testExtension = {
|
||||
name: 'test-extension',
|
||||
onCaptchaValidate: async (event) => {
|
||||
if (event.type === 'login' && event.ip === 'trusted-ip') {
|
||||
event.require = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Register extension and event handler
|
||||
extensionService.registerExtension(testExtension.name, testExtension);
|
||||
extensionService.on('captcha.validate', testExtension.onCaptchaValidate);
|
||||
|
||||
// Test event emission
|
||||
const eventData = {
|
||||
type: 'login',
|
||||
ip: 'trusted-ip',
|
||||
require: true
|
||||
};
|
||||
|
||||
await extensionService.emit('captcha.validate', eventData);
|
||||
|
||||
// Assert
|
||||
expect(eventData.require).to.be.false;
|
||||
});
|
||||
|
||||
it('should handle multiple extensions modifying captcha requirement', async () => {
|
||||
// Setup - create two test extensions with different rules
|
||||
const extension1 = {
|
||||
name: 'extension-1',
|
||||
onCaptchaValidate: async (event) => {
|
||||
if (event.type === 'login') {
|
||||
event.require = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const extension2 = {
|
||||
name: 'extension-2',
|
||||
onCaptchaValidate: async (event) => {
|
||||
if (event.ip === 'trusted-ip') {
|
||||
event.require = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Register extensions and event handlers
|
||||
extensionService.registerExtension(extension1.name, extension1);
|
||||
extensionService.registerExtension(extension2.name, extension2);
|
||||
extensionService.on('captcha.validate', extension1.onCaptchaValidate);
|
||||
extensionService.on('captcha.validate', extension2.onCaptchaValidate);
|
||||
|
||||
// Test event emission - extension2 should override extension1
|
||||
const eventData = {
|
||||
type: 'login',
|
||||
ip: 'trusted-ip',
|
||||
require: false
|
||||
};
|
||||
|
||||
await extensionService.emit('captcha.validate', eventData);
|
||||
|
||||
// Assert
|
||||
expect(eventData.require).to.be.false;
|
||||
});
|
||||
|
||||
it('should handle extension errors gracefully', async () => {
|
||||
// Setup - create a test extension that throws an error
|
||||
const testExtension = {
|
||||
name: 'test-extension',
|
||||
onCaptchaValidate: async () => {
|
||||
throw new Error('Extension error');
|
||||
}
|
||||
};
|
||||
|
||||
// Register extension and event handler
|
||||
extensionService.registerExtension(testExtension.name, testExtension);
|
||||
extensionService.on('captcha.validate', testExtension.onCaptchaValidate);
|
||||
|
||||
// Test event emission
|
||||
const eventData = {
|
||||
type: 'login',
|
||||
ip: '1.2.3.4',
|
||||
require: false
|
||||
};
|
||||
|
||||
// The emit should not throw
|
||||
await extensionService.emit('captcha.validate', eventData);
|
||||
|
||||
// Assert - the original value should be preserved
|
||||
expect(eventData.require).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backward Compatibility', () => {
|
||||
it('should maintain backward compatibility with older extension APIs', async () => {
|
||||
// Setup - create a test extension using the old API format
|
||||
const legacyExtension = {
|
||||
name: 'legacy-extension',
|
||||
handleCaptcha: async (event) => {
|
||||
event.require = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Register legacy extension with old event name
|
||||
extensionService.registerExtension(legacyExtension.name, legacyExtension);
|
||||
extensionService.on('captcha.check', legacyExtension.handleCaptcha);
|
||||
|
||||
// Test both old and new event names
|
||||
const eventData = {
|
||||
type: 'login',
|
||||
ip: '1.2.3.4',
|
||||
require: false
|
||||
};
|
||||
|
||||
// Should work with both old and new event names
|
||||
await extensionService.emit('captcha.check', eventData);
|
||||
await extensionService.emit('captcha.validate', eventData);
|
||||
|
||||
// Assert - the requirement should be set by the legacy extension
|
||||
expect(eventData.require).to.be.true;
|
||||
});
|
||||
|
||||
it('should support legacy extension configuration formats', async () => {
|
||||
// Setup - create a test extension with legacy configuration
|
||||
const legacyExtension = {
|
||||
name: 'legacy-extension',
|
||||
config: {
|
||||
captcha: {
|
||||
always: true,
|
||||
types: ['login', 'signup']
|
||||
}
|
||||
},
|
||||
onCaptchaValidate: async (event) => {
|
||||
if (legacyExtension.config.captcha.types.includes(event.type)) {
|
||||
event.require = legacyExtension.config.captcha.always;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Register extension and event handler
|
||||
extensionService.registerExtension(legacyExtension.name, legacyExtension);
|
||||
extensionService.on('captcha.validate', legacyExtension.onCaptchaValidate);
|
||||
|
||||
// Test event emission
|
||||
const eventData = {
|
||||
type: 'login',
|
||||
ip: '1.2.3.4',
|
||||
require: false
|
||||
};
|
||||
|
||||
await extensionService.emit('captcha.validate', eventData);
|
||||
|
||||
// Assert
|
||||
expect(eventData.require).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require('mocha');
|
||||
const { expect } = require('chai');
|
||||
const sinon = require('sinon');
|
||||
|
||||
// Mock the Context
|
||||
const Context = {
|
||||
get: sinon.stub()
|
||||
};
|
||||
|
||||
// Mock the APIError
|
||||
const APIError = {
|
||||
create: sinon.stub().returns({ name: 'APIError' })
|
||||
};
|
||||
|
||||
// Path is relative to where the test will be run
|
||||
const { checkCaptcha, requireCaptcha } = require('../../../../src/modules/captcha/middleware/captcha-middleware');
|
||||
|
||||
describe('Captcha Middleware', () => {
|
||||
let req, res, next, services, captchaService, eventService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all stubs
|
||||
sinon.reset();
|
||||
|
||||
// Mock request, response, and next function
|
||||
req = {
|
||||
ip: '127.0.0.1',
|
||||
headers: {
|
||||
'user-agent': 'test-agent'
|
||||
},
|
||||
body: {},
|
||||
connection: {
|
||||
remoteAddress: '127.0.0.1'
|
||||
}
|
||||
};
|
||||
|
||||
res = {
|
||||
status: sinon.stub().returnsThis(),
|
||||
json: sinon.stub().returnsThis()
|
||||
};
|
||||
|
||||
next = sinon.stub();
|
||||
|
||||
// Mock services
|
||||
captchaService = {
|
||||
enabled: true,
|
||||
verifyCaptcha: sinon.stub()
|
||||
};
|
||||
|
||||
eventService = {
|
||||
emit: sinon.stub().resolves()
|
||||
};
|
||||
|
||||
services = {
|
||||
get: sinon.stub()
|
||||
};
|
||||
|
||||
// Configure service mocks
|
||||
services.get.withArgs('captcha').returns(captchaService);
|
||||
services.get.withArgs('event').returns(eventService);
|
||||
|
||||
// Configure Context mock
|
||||
Context.get.withArgs('services').returns(services);
|
||||
});
|
||||
|
||||
describe('checkCaptcha', () => {
|
||||
it('should set captchaRequired to false when not required', async () => {
|
||||
// Setup
|
||||
const middleware = checkCaptcha({ strictMode: false });
|
||||
|
||||
// Test
|
||||
await middleware(req, res, next);
|
||||
|
||||
// Assert
|
||||
expect(req.captchaRequired).to.be.false;
|
||||
expect(next.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should set captchaRequired to true when always option is true', async () => {
|
||||
// Setup
|
||||
const middleware = checkCaptcha({ always: true });
|
||||
|
||||
// Test
|
||||
await middleware(req, res, next);
|
||||
|
||||
// Assert
|
||||
expect(req.captchaRequired).to.be.true;
|
||||
expect(next.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should set captchaRequired to true when requester.requireCaptcha is true', async () => {
|
||||
// Setup
|
||||
req.requester = { requireCaptcha: true };
|
||||
const middleware = checkCaptcha();
|
||||
|
||||
// Test
|
||||
await middleware(req, res, next);
|
||||
|
||||
// Assert
|
||||
expect(req.captchaRequired).to.be.true;
|
||||
expect(next.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should emit captcha.validate event with correct parameters', async () => {
|
||||
// Setup
|
||||
const middleware = checkCaptcha({ eventType: 'login' });
|
||||
|
||||
// Test
|
||||
await middleware(req, res, next);
|
||||
|
||||
// Assert
|
||||
expect(eventService.emit.calledOnce).to.be.true;
|
||||
expect(eventService.emit.firstCall.args[0]).to.equal('captcha.validate');
|
||||
|
||||
const eventData = eventService.emit.firstCall.args[1];
|
||||
expect(eventData.type).to.equal('login');
|
||||
expect(eventData.ip).to.equal('127.0.0.1');
|
||||
expect(eventData.userAgent).to.equal('test-agent');
|
||||
expect(eventData.req).to.equal(req);
|
||||
});
|
||||
|
||||
it('should respect extension decision to require captcha', async () => {
|
||||
// Setup
|
||||
eventService.emit.callsFake((event, data) => {
|
||||
data.require = true;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const middleware = checkCaptcha({ strictMode: false });
|
||||
|
||||
// Test
|
||||
await middleware(req, res, next);
|
||||
|
||||
// Assert
|
||||
expect(req.captchaRequired).to.be.true;
|
||||
expect(next.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should respect extension decision to not require captcha', async () => {
|
||||
// Setup
|
||||
eventService.emit.callsFake((event, data) => {
|
||||
data.require = false;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const middleware = checkCaptcha({ always: true });
|
||||
|
||||
// Test
|
||||
await middleware(req, res, next);
|
||||
|
||||
// Assert
|
||||
expect(req.captchaRequired).to.be.false;
|
||||
expect(next.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should default to strictMode value when services are not available', async () => {
|
||||
// Setup
|
||||
Context.get.withArgs('services').returns(null);
|
||||
|
||||
// Test with strictMode true
|
||||
let middleware = checkCaptcha({ strictMode: true });
|
||||
await middleware(req, res, next);
|
||||
expect(req.captchaRequired).to.be.true;
|
||||
|
||||
// Reset
|
||||
req = { headers: {}, connection: { remoteAddress: '127.0.0.1' } };
|
||||
next = sinon.stub();
|
||||
|
||||
// Test with strictMode false
|
||||
middleware = checkCaptcha({ strictMode: false });
|
||||
await middleware(req, res, next);
|
||||
expect(req.captchaRequired).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireCaptcha', () => {
|
||||
it('should call next() when captchaRequired is false', async () => {
|
||||
// Setup
|
||||
req.captchaRequired = false;
|
||||
const middleware = requireCaptcha();
|
||||
|
||||
// Test
|
||||
await middleware(req, res, next);
|
||||
|
||||
// Assert
|
||||
expect(next.calledOnce).to.be.true;
|
||||
expect(next.firstCall.args.length).to.equal(0); // No error passed
|
||||
});
|
||||
|
||||
it('should return error when captchaRequired is true but token/answer missing', async () => {
|
||||
// Setup
|
||||
req.captchaRequired = true;
|
||||
const middleware = requireCaptcha();
|
||||
|
||||
// Test
|
||||
await middleware(req, res, next);
|
||||
|
||||
// Assert
|
||||
expect(next.calledOnce).to.be.true;
|
||||
expect(next.firstCall.args.length).to.equal(1); // Error passed
|
||||
expect(APIError.create.calledWith('captcha_required')).to.be.true;
|
||||
});
|
||||
|
||||
it('should verify captcha when token and answer are provided', async () => {
|
||||
// Setup
|
||||
req.captchaRequired = true;
|
||||
req.body.captchaToken = 'test-token';
|
||||
req.body.captchaAnswer = 'test-answer';
|
||||
captchaService.verifyCaptcha.returns(true);
|
||||
|
||||
const middleware = requireCaptcha();
|
||||
|
||||
// Test
|
||||
await middleware(req, res, next);
|
||||
|
||||
// Assert
|
||||
expect(captchaService.verifyCaptcha.calledWith('test-token', 'test-answer')).to.be.true;
|
||||
expect(next.calledOnce).to.be.true;
|
||||
expect(next.firstCall.args.length).to.equal(0); // No error passed
|
||||
});
|
||||
|
||||
it('should return error when captcha verification fails', async () => {
|
||||
// Setup
|
||||
req.captchaRequired = true;
|
||||
req.body.captchaToken = 'test-token';
|
||||
req.body.captchaAnswer = 'test-answer';
|
||||
captchaService.verifyCaptcha.returns(false);
|
||||
|
||||
const middleware = requireCaptcha();
|
||||
|
||||
// Test
|
||||
await middleware(req, res, next);
|
||||
|
||||
// Assert
|
||||
expect(captchaService.verifyCaptcha.calledWith('test-token', 'test-answer')).to.be.true;
|
||||
expect(next.calledOnce).to.be.true;
|
||||
expect(next.firstCall.args.length).to.equal(1); // Error passed
|
||||
expect(APIError.create.calledWith('captcha_invalid')).to.be.true;
|
||||
});
|
||||
|
||||
it('should handle errors during captcha verification', async () => {
|
||||
// Setup
|
||||
req.captchaRequired = true;
|
||||
req.body.captchaToken = 'test-token';
|
||||
req.body.captchaAnswer = 'test-answer';
|
||||
captchaService.verifyCaptcha.throws(new Error('Verification error'));
|
||||
|
||||
const middleware = requireCaptcha();
|
||||
|
||||
// Test
|
||||
await middleware(req, res, next);
|
||||
|
||||
// Assert
|
||||
expect(captchaService.verifyCaptcha.calledWith('test-token', 'test-answer')).to.be.true;
|
||||
expect(next.calledOnce).to.be.true;
|
||||
expect(next.firstCall.args.length).to.equal(1); // Error passed
|
||||
expect(APIError.create.calledWith('captcha_invalid')).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.1.1",
|
||||
"chai": "^4.3.7",
|
||||
"chalk": "^4.1.0",
|
||||
"clean-css": "^5.3.2",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -19,13 +20,15 @@
|
||||
"express": "^4.18.2",
|
||||
"globals": "^15.0.0",
|
||||
"html-entities": "^2.3.3",
|
||||
"jsdom": "^21.1.0",
|
||||
"nodemon": "^3.1.0",
|
||||
"sinon": "^15.0.1",
|
||||
"uglify-js": "^3.17.4",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "mocha ./packages/phoenix/test ./packages/phoenix/packages/contextlink/test",
|
||||
"test": "mocha ./test/**/*.test.js",
|
||||
"start=gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"build": "node ./build.js",
|
||||
"check-translations": "node tools/check-translations.js",
|
||||
|
||||
285
src/gui/src/UI/Components/CaptchaView.js
Normal file
285
src/gui/src/UI/Components/CaptchaView.js
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* CaptchaView - A component for displaying and handling captcha challenges
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {HTMLElement} options.container - The container element to attach the captcha to
|
||||
* @param {Function} options.onReady - Callback when the captcha is ready
|
||||
* @param {Function} options.onError - Callback for handling errors
|
||||
* @param {boolean} options.required - Whether captcha is required (will not display if false)
|
||||
* @param {Function} options.onRequiredChange - Callback when the required status changes
|
||||
* @returns {Object} - Methods to interact with the captcha
|
||||
*/
|
||||
function CaptchaView(options = {}) {
|
||||
// Internal state
|
||||
const state = {
|
||||
token: null,
|
||||
image: null,
|
||||
answer: '',
|
||||
loading: false,
|
||||
error: null,
|
||||
container: options.container || document.createElement('div'),
|
||||
required: options.required !== undefined ? options.required : true, // Default to required
|
||||
initialized: false
|
||||
};
|
||||
|
||||
// Create the initial DOM structure
|
||||
const init = () => {
|
||||
const container = state.container;
|
||||
container.classList.add('captcha-view-container');
|
||||
container.style.marginTop = '20px';
|
||||
container.style.marginBottom = '20px';
|
||||
|
||||
// Add container CSS
|
||||
container.style.display = state.required ? 'flex' : 'none';
|
||||
container.style.flexDirection = 'column';
|
||||
container.style.gap = '10px';
|
||||
|
||||
state.initialized = true;
|
||||
|
||||
// Render the initial HTML
|
||||
render();
|
||||
|
||||
// Only fetch captcha if required
|
||||
if (state.required) {
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
// Set whether captcha is required
|
||||
const setRequired = (required) => {
|
||||
if (state.required === required) return; // No change
|
||||
|
||||
state.required = required;
|
||||
|
||||
if (state.initialized) {
|
||||
// Update display
|
||||
state.container.style.display = required ? 'flex' : 'none';
|
||||
|
||||
// If becoming required and no captcha loaded, fetch one
|
||||
if (required && !state.token) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
// Notify of change if callback provided
|
||||
if (typeof options.onRequiredChange === 'function') {
|
||||
options.onRequiredChange(required);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Render the captcha HTML
|
||||
const render = () => {
|
||||
const container = state.container;
|
||||
|
||||
// Clear the container
|
||||
container.innerHTML = '';
|
||||
|
||||
// Label
|
||||
const label = document.createElement('label');
|
||||
label.textContent = i18n('captcha_verification');
|
||||
label.setAttribute('for', `captcha-input-${Date.now()}`);
|
||||
container.appendChild(label);
|
||||
|
||||
// Captcha wrapper
|
||||
const captchaWrapper = document.createElement('div');
|
||||
captchaWrapper.classList.add('captcha-wrapper');
|
||||
captchaWrapper.style.display = 'flex';
|
||||
captchaWrapper.style.flexDirection = 'column';
|
||||
captchaWrapper.style.gap = '10px';
|
||||
container.appendChild(captchaWrapper);
|
||||
|
||||
// Captcha image and refresh button container
|
||||
const imageContainer = document.createElement('div');
|
||||
imageContainer.style.display = 'flex';
|
||||
imageContainer.style.alignItems = 'center';
|
||||
imageContainer.style.justifyContent = 'space-between';
|
||||
imageContainer.style.gap = '10px';
|
||||
imageContainer.style.border = '1px solid #ced7e1';
|
||||
imageContainer.style.borderRadius = '4px';
|
||||
imageContainer.style.padding = '10px';
|
||||
captchaWrapper.appendChild(imageContainer);
|
||||
|
||||
// Captcha image
|
||||
const imageElement = document.createElement('div');
|
||||
imageElement.classList.add('captcha-image');
|
||||
|
||||
if (state.loading) {
|
||||
imageElement.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:50px;"><span style="font-size:14px;">Loading captcha...</span></div>';
|
||||
} else if (state.error) {
|
||||
imageElement.innerHTML = `<div style="color:red;padding:10px;">${state.error}</div>`;
|
||||
} else if (state.image) {
|
||||
imageElement.innerHTML = state.image;
|
||||
// Make SVG responsive
|
||||
const svgElement = imageElement.querySelector('svg');
|
||||
if (svgElement) {
|
||||
svgElement.style.width = '100%';
|
||||
svgElement.style.height = 'auto';
|
||||
}
|
||||
} else {
|
||||
imageElement.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:50px;"><span style="font-size:14px;">No captcha loaded</span></div>';
|
||||
}
|
||||
imageContainer.appendChild(imageElement);
|
||||
|
||||
// Refresh button
|
||||
const refreshButton = document.createElement('button');
|
||||
refreshButton.classList.add('button', 'button-small');
|
||||
refreshButton.innerHTML = '<i class="fas fa-sync-alt"></i>';
|
||||
refreshButton.setAttribute('title', i18n('refresh_captcha'));
|
||||
refreshButton.style.minWidth = '30px';
|
||||
refreshButton.style.height = '30px';
|
||||
refreshButton.setAttribute('type', 'button');
|
||||
refreshButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
refresh();
|
||||
});
|
||||
imageContainer.appendChild(refreshButton);
|
||||
|
||||
// Input field
|
||||
const inputField = document.createElement('input');
|
||||
inputField.id = `captcha-input-${Date.now()}`;
|
||||
inputField.classList.add('captcha-input');
|
||||
inputField.type = 'text';
|
||||
inputField.placeholder = i18n('enter_captcha_text');
|
||||
inputField.setAttribute('autocomplete', 'off');
|
||||
inputField.setAttribute('spellcheck', 'false');
|
||||
inputField.setAttribute('autocorrect', 'off');
|
||||
inputField.setAttribute('autocapitalize', 'off');
|
||||
inputField.value = state.answer || '';
|
||||
inputField.addEventListener('input', (e) => {
|
||||
state.answer = e.target.value;
|
||||
});
|
||||
|
||||
// Prevent Enter key from triggering refresh and allow it to submit the form
|
||||
inputField.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// Don't prevent default here - let Enter bubble up to the form
|
||||
// Just make sure we don't refresh the captcha
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
captchaWrapper.appendChild(inputField);
|
||||
|
||||
// Helper text
|
||||
const helperText = document.createElement('div');
|
||||
helperText.classList.add('captcha-helper-text');
|
||||
helperText.style.fontSize = '12px';
|
||||
helperText.style.color = '#666';
|
||||
helperText.textContent = i18n('captcha_case_sensitive');
|
||||
captchaWrapper.appendChild(helperText);
|
||||
};
|
||||
|
||||
// Fetch a new captcha
|
||||
const refresh = async () => {
|
||||
// Skip if not required
|
||||
if (!state.required) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
render();
|
||||
|
||||
const response = await fetch(window.gui_origin + '/api/captcha/generate');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load captcha: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
state.token = data.token;
|
||||
state.image = data.image;
|
||||
state.loading = false;
|
||||
|
||||
render();
|
||||
|
||||
if (typeof options.onReady === 'function') {
|
||||
options.onReady();
|
||||
}
|
||||
} catch (error) {
|
||||
state.loading = false;
|
||||
state.error = error.message || 'Failed to load captcha';
|
||||
|
||||
render();
|
||||
|
||||
if (typeof options.onError === 'function') {
|
||||
options.onError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Public API
|
||||
const api = {
|
||||
/**
|
||||
* Get the current captcha token
|
||||
* @returns {string} The captcha token
|
||||
*/
|
||||
getToken: () => state.token,
|
||||
|
||||
/**
|
||||
* Get the current captcha answer
|
||||
* @returns {string} The user's answer
|
||||
*/
|
||||
getAnswer: () => state.answer,
|
||||
|
||||
/**
|
||||
* Reset the captcha - clear answer and get a new challenge
|
||||
*/
|
||||
reset: () => {
|
||||
state.answer = '';
|
||||
refresh();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the container element
|
||||
* @returns {HTMLElement} The container element
|
||||
*/
|
||||
getElement: () => state.container,
|
||||
|
||||
/**
|
||||
* Check if captcha is required
|
||||
* @returns {boolean} Whether captcha is required
|
||||
*/
|
||||
isRequired: () => state.required,
|
||||
|
||||
/**
|
||||
* Set whether captcha is required
|
||||
* @param {boolean} required - Whether captcha is required
|
||||
*/
|
||||
setRequired: setRequired
|
||||
};
|
||||
|
||||
// Set initial required state from options
|
||||
if (options.required !== undefined) {
|
||||
state.required = options.required;
|
||||
}
|
||||
|
||||
// Initialize the component
|
||||
init();
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
export default CaptchaView;
|
||||
@@ -29,43 +29,61 @@ import StepView from './Components/StepView.js';
|
||||
import Button from './Components/Button.js';
|
||||
import RecoveryCodeEntryView from './Components/RecoveryCodeEntryView.js';
|
||||
import play_startup_chime from '../helpers/play_startup_chime.js';
|
||||
import CaptchaView from './Components/CaptchaView.js'
|
||||
import { isCaptchaRequired } from '../helpers/captchaHelper.js';
|
||||
|
||||
async function UIWindowLogin(options){
|
||||
options = options ?? {};
|
||||
options.reload_on_success = options.reload_on_success ?? false;
|
||||
options.has_head = options.has_head ?? true;
|
||||
options.send_confirmation_code = options.send_confirmation_code ?? false;
|
||||
options.show_password = options.show_password ?? false;
|
||||
|
||||
|
||||
if(options.reload_on_success === undefined)
|
||||
options.reload_on_success = true;
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
const internal_id = window.uuidv4();
|
||||
|
||||
// Check if captcha is required for login
|
||||
const captchaRequired = await isCaptchaRequired('login');
|
||||
console.log('Login captcha required:', captchaRequired);
|
||||
|
||||
let h = ``;
|
||||
h += `<div style="max-width: 500px; min-width: 340px;">`;
|
||||
if(!options.has_head && options.show_close_button !== false)
|
||||
h += `<div class="generic-close-window-button"> × </div>`;
|
||||
h += `<div style="padding: 20px; border-bottom: 1px solid #ced7e1; width: 100%; box-sizing: border-box;">`;
|
||||
// title
|
||||
h += `<h1 class="login-form-title">${i18n('log_in')}</h1>`;
|
||||
// login form
|
||||
h += `<form class="login-form">`;
|
||||
// error msg
|
||||
h += `<div class="login-error-msg"></div>`;
|
||||
// username/email
|
||||
h += `<div style="overflow: hidden;">`;
|
||||
h += `<label for="email_or_username-${internal_id}">${i18n('email_or_username')}</label>`;
|
||||
h += `<input id="email_or_username-${internal_id}" class="email_or_username" type="text" name="email_or_username" spellcheck="false" autocorrect="off" autocapitalize="off" data-gramm_editor="false" autocomplete="username"/>`;
|
||||
h += `<div style="max-width:100%; width:100%; height:100%; min-height:0; box-sizing:border-box; display:flex; flex-direction:column; justify-content:flex-start; align-items:stretch; padding:0; overflow:auto; color:var(--color-text);">`;
|
||||
// logo
|
||||
h += `<div class="logo-wrapper" style="display:flex; justify-content:center; padding:20px 20px 0 20px; margin-bottom: 0;">`;
|
||||
h += `<img src="/dist/images/logo/logo.svg" style="height:45px;" />`;
|
||||
h += `</div>`;
|
||||
// title
|
||||
h += `<div style="padding:10px 20px; text-align:center; margin-bottom:0;">`;
|
||||
h += `<h1 style="font-size:18px; margin-bottom:0;">${i18n('log_in')}</h1>`;
|
||||
h += `</div>`;
|
||||
// form
|
||||
h += `<div style="padding:20px; overflow-y:auto; overflow-x:hidden;">`;
|
||||
h += `<form class="login-form" style="width:100%;">`;
|
||||
// server messages
|
||||
h += `<div class="login-error-msg" style="color:#e74c3c; display:none; margin-bottom:10px; line-height:15px; font-size:13px;"></div>`;
|
||||
// email or username
|
||||
h += `<div style="position: relative; margin-bottom: 20px;">`;
|
||||
h += `<label style="display:block; margin-bottom:5px;">${i18n('email_or_username')}</label>`;
|
||||
if(options.email_or_username){
|
||||
h += `<input type="text" class="email_or_username" value="${options.email_or_username}" autocomplete="username"/>`;
|
||||
}else{
|
||||
h += `<input type="text" class="email_or_username" autocomplete="username"/>`;
|
||||
}
|
||||
h += `</div>`;
|
||||
// password with conditional type based based on options.show_password
|
||||
h += `<div style="overflow: hidden; margin-top: 20px; margin-bottom: 20px; position: relative;">`;
|
||||
h += `<label for="password-${internal_id}">${i18n('password')}</label>`;
|
||||
// password
|
||||
h += `<div style="position: relative; margin-bottom: 20px;">`;
|
||||
h += `<label style="display:block; margin-bottom:5px;">${i18n('password')}</label>`;
|
||||
h += `<input id="password-${internal_id}" class="password" type="${options.show_password ? "text" : "password"}" name="password" autocomplete="current-password"/>`;
|
||||
// show/hide icon
|
||||
h += `<span style="position: absolute; right: 5%; top: 50%; cursor: pointer;" id="toggle-show-password-${internal_id}">
|
||||
<img class="toggle-show-password-icon" src="${options.show_password ? window.icons["eye-closed.svg"] : window.icons["eye-open.svg"]}" width="20" height="20">
|
||||
</span>`;
|
||||
h += `</div>`;
|
||||
// captcha placeholder - will be replaced with actual captcha component
|
||||
h += `<div class="captcha-container"></div>`;
|
||||
// captcha-specific error message
|
||||
h += `<div class="captcha-error-msg" style="color: #e74c3c; font-size: 12px; margin-top: 5px; display: none;" aria-live="polite"></div>`;
|
||||
// login
|
||||
h += `<button class="login-btn button button-primary button-block button-normal">${i18n('log_in')}</button>`;
|
||||
h += `<button type="submit" class="login-btn button button-primary button-block button-normal">${i18n('log_in')}</button>`;
|
||||
// password recovery
|
||||
h += `<p style="text-align:center; margin-bottom: 0;"><span class="forgot-password-link">${i18n('forgot_pass_c2a')}</span></p>`;
|
||||
h += `</form>`;
|
||||
@@ -124,6 +142,46 @@ async function UIWindowLogin(options){
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize the captcha component with the required state
|
||||
const captchaContainer = $(el_window).find('.captcha-container')[0];
|
||||
const captcha = CaptchaView({
|
||||
container: captchaContainer,
|
||||
required: captchaRequired,
|
||||
});
|
||||
|
||||
// Function to show captcha-specific error
|
||||
const showCaptchaError = (message) => {
|
||||
// Hide the general error message if shown
|
||||
$(el_window).find('.login-error-msg').hide();
|
||||
|
||||
// Show captcha-specific error
|
||||
const captchaError = $(el_window).find('.captcha-error-msg');
|
||||
captchaError.html(message);
|
||||
captchaError.fadeIn();
|
||||
|
||||
// Add visual indication of error to captcha container
|
||||
$(captchaContainer).addClass('error');
|
||||
$(captchaContainer).css('border', '1px solid #e74c3c');
|
||||
$(captchaContainer).css('border-radius', '4px');
|
||||
$(captchaContainer).css('padding', '10px');
|
||||
|
||||
// Focus on the captcha input for better UX
|
||||
setTimeout(() => {
|
||||
const captchaInput = $(captchaContainer).find('.captcha-input');
|
||||
if (captchaInput.length) {
|
||||
captchaInput.focus();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Function to clear captcha errors
|
||||
const clearCaptchaError = () => {
|
||||
$(el_window).find('.captcha-error-msg').hide();
|
||||
$(captchaContainer).removeClass('error');
|
||||
$(captchaContainer).css('border', '');
|
||||
$(captchaContainer).css('padding', '');
|
||||
};
|
||||
|
||||
$(el_window).find('.forgot-password-link').on('click', function(e){
|
||||
UIWindowRecoverPassword({
|
||||
window_options: {
|
||||
@@ -135,36 +193,105 @@ async function UIWindowLogin(options){
|
||||
})
|
||||
|
||||
$(el_window).find('.login-btn').on('click', function(e){
|
||||
// Prevent default button behavior (important for async requests)
|
||||
e.preventDefault();
|
||||
|
||||
// Clear previous error states
|
||||
$(el_window).find('.login-error-msg').hide();
|
||||
clearCaptchaError();
|
||||
|
||||
const email_username = $(el_window).find('.email_or_username').val();
|
||||
const password = $(el_window).find('.password').val();
|
||||
|
||||
// Basic validation for email/username and password
|
||||
if(!email_username) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('email_or_username_required') || 'Email or username is required');
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
if(!password) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('password_required') || 'Password is required');
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get captcha token and answer if required
|
||||
let captchaToken = null;
|
||||
let captchaAnswer = null;
|
||||
|
||||
if (captcha.isRequired()) {
|
||||
captchaToken = captcha.getToken();
|
||||
captchaAnswer = captcha.getAnswer();
|
||||
|
||||
// Validate captcha if it's required
|
||||
if (!captcha || !captchaContainer) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('captcha_system_error') || 'Verification system error. Please refresh the page.');
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!captchaToken) {
|
||||
showCaptchaError(i18n('captcha_load_error') || 'Could not load verification code. Please refresh the page or try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!captchaAnswer) {
|
||||
showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
|
||||
return;
|
||||
}
|
||||
|
||||
if (captchaAnswer.trim().length < 3) {
|
||||
showCaptchaError(i18n('captcha_too_short') || 'Verification code answer is too short.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (captchaAnswer.trim().length > 12) {
|
||||
showCaptchaError(i18n('captcha_too_long') || 'Verification code answer is too long.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare data for the request
|
||||
let data;
|
||||
|
||||
if(window.is_email(email_username)){
|
||||
data = JSON.stringify({
|
||||
email: email_username,
|
||||
password: password
|
||||
})
|
||||
}else{
|
||||
password: password,
|
||||
...(captchaToken && captchaAnswer ? {
|
||||
captchaToken: captchaToken,
|
||||
captchaAnswer: captchaAnswer
|
||||
} : {})
|
||||
});
|
||||
} else {
|
||||
data = JSON.stringify({
|
||||
username: email_username,
|
||||
password: password
|
||||
})
|
||||
password: password,
|
||||
...(captchaToken && captchaAnswer ? {
|
||||
captchaToken: captchaToken,
|
||||
captchaAnswer: captchaAnswer
|
||||
} : {})
|
||||
});
|
||||
}
|
||||
|
||||
$(el_window).find('.login-error-msg').hide();
|
||||
|
||||
let headers = {};
|
||||
if(window.custom_headers)
|
||||
headers = window.custom_headers;
|
||||
|
||||
// Disable the login button to prevent multiple submissions
|
||||
$(el_window).find('.login-btn').prop('disabled', true);
|
||||
|
||||
console.log('Sending login AJAX request with async: true');
|
||||
$.ajax({
|
||||
url: window.gui_origin + "/login",
|
||||
type: 'POST',
|
||||
async: false,
|
||||
async: true,
|
||||
headers: headers,
|
||||
contentType: "application/json",
|
||||
data: data,
|
||||
success: async function (data){
|
||||
console.log('Login request successful');
|
||||
// Keep the button disabled on success since we're redirecting or closing
|
||||
let p = Promise.resolve();
|
||||
if ( data.next_step === 'otp' ) {
|
||||
p = new TeePromise();
|
||||
@@ -334,12 +461,96 @@ async function UIWindowLogin(options){
|
||||
|
||||
if(options.reload_on_success){
|
||||
window.onbeforeunload = null;
|
||||
window.location.replace('/');
|
||||
console.log('About to redirect, checking URL parameters:', window.location.search);
|
||||
// Replace with a clean URL to prevent password leakage
|
||||
const cleanUrl = window.location.origin + window.location.pathname;
|
||||
window.location.replace(cleanUrl);
|
||||
}else
|
||||
resolve(true);
|
||||
$(el_window).close();
|
||||
},
|
||||
error: function (err){
|
||||
console.log('Login AJAX request error:', err.status, err.statusText);
|
||||
|
||||
// First, ensure URL is clean in case of error (prevent password leakage)
|
||||
if (window.location.search && (
|
||||
window.location.search.includes('password=') ||
|
||||
window.location.search.includes('username=') ||
|
||||
window.location.search.includes('email=')
|
||||
)) {
|
||||
console.log('Cleaning sensitive data from URL');
|
||||
const cleanUrl = window.location.origin + window.location.pathname;
|
||||
history.replaceState({}, document.title, cleanUrl);
|
||||
}
|
||||
|
||||
// Enable 'Log In' button
|
||||
$(el_window).find('.login-btn').prop('disabled', false);
|
||||
|
||||
// Handle captcha-specific errors
|
||||
const errorText = err.responseText || '';
|
||||
const errorStatus = err.status || 0;
|
||||
|
||||
// Try to parse error as JSON
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
|
||||
// Check for specific error codes
|
||||
if (errorJson.code === 'captcha_required') {
|
||||
// If captcha is now required but wasn't before, update the component
|
||||
if (!captcha.isRequired()) {
|
||||
captcha.setRequired(true);
|
||||
showCaptchaError(i18n('captcha_now_required') || 'Verification is now required. Please complete the verification below.');
|
||||
} else {
|
||||
showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorJson.code === 'captcha_invalid' || errorJson.code === 'captcha_error') {
|
||||
showCaptchaError(i18n('captcha_invalid') || 'Invalid verification code');
|
||||
// Refresh the captcha if it's invalid
|
||||
captcha.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a message in the JSON, use that
|
||||
if (errorJson.message) {
|
||||
$(el_window).find('.login-error-msg').html(errorJson.message);
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, continue with text analysis
|
||||
}
|
||||
|
||||
// Check for specific captcha errors using more robust detection for text responses
|
||||
if (
|
||||
errorText.includes('captcha_required') ||
|
||||
errorText.includes('Captcha verification required') ||
|
||||
(errorText.includes('captcha') && errorText.includes('required'))
|
||||
) {
|
||||
// If captcha is now required but wasn't before, update the component
|
||||
if (!captcha.isRequired()) {
|
||||
captcha.setRequired(true);
|
||||
showCaptchaError(i18n('captcha_now_required') || 'Verification is now required. Please complete the verification below.');
|
||||
} else {
|
||||
showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
errorText.includes('captcha_invalid') ||
|
||||
errorText.includes('Invalid captcha') ||
|
||||
(errorText.includes('captcha') && (errorText.includes('invalid') || errorText.includes('incorrect')))
|
||||
) {
|
||||
showCaptchaError(i18n('captcha_invalid') || 'Invalid verification code');
|
||||
// Refresh the captcha if it's invalid
|
||||
captcha.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to original error handling
|
||||
const $errorMessage = $(el_window).find('.login-error-msg');
|
||||
if (err.status === 404) {
|
||||
// Don't include the whole 404 page
|
||||
@@ -372,6 +583,27 @@ async function UIWindowLogin(options){
|
||||
$(el_window).find('.login-form').on('submit', function(e){
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Instead of triggering the click event, process the login directly
|
||||
const email_username = $(el_window).find('.email_or_username').val();
|
||||
const password = $(el_window).find('.password').val();
|
||||
|
||||
// Basic validation
|
||||
if(!email_username) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('email_or_username_required') || 'Email or username is required');
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return false;
|
||||
}
|
||||
|
||||
if(!password) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('password_required') || 'Password is required');
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process login using the same function as the button click
|
||||
$(el_window).find('.login-btn').click();
|
||||
|
||||
return false;
|
||||
})
|
||||
|
||||
|
||||
@@ -21,15 +21,23 @@ import UIWindow from './UIWindow.js'
|
||||
import UIWindowLogin from './UIWindowLogin.js'
|
||||
import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js'
|
||||
import check_password_strength from '../helpers/check_password_strength.js'
|
||||
import CaptchaView from './Components/CaptchaView.js'
|
||||
import { isCaptchaRequired } from '../helpers/captchaHelper.js'
|
||||
|
||||
function UIWindowSignup(options){
|
||||
options = options ?? {};
|
||||
options.reload_on_success = options.reload_on_success ?? false;
|
||||
options.reload_on_success = options.reload_on_success ?? true;
|
||||
options.has_head = options.has_head ?? true;
|
||||
options.send_confirmation_code = options.send_confirmation_code ?? false;
|
||||
options.show_close_button = options.show_close_button ?? true;
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
const internal_id = window.uuidv4();
|
||||
|
||||
// Check if captcha is required for signup
|
||||
const captchaRequired = await isCaptchaRequired('signup');
|
||||
console.log('Signup captcha required:', captchaRequired);
|
||||
|
||||
let h = '';
|
||||
h += `<div style="margin: 0 auto; max-width: 500px; min-width: 400px;">`;
|
||||
// logo
|
||||
@@ -61,6 +69,10 @@ function UIWindowSignup(options){
|
||||
h += `<label for="password-${internal_id}">${i18n('password')}</label>`;
|
||||
h += `<input id="password-${internal_id}" class="password" type="password" name="password" autocomplete="new-password" />`;
|
||||
h += `</div>`;
|
||||
// captcha placeholder - will be replaced with actual captcha component
|
||||
h += `<div class="captcha-container"></div>`;
|
||||
// captcha-specific error message
|
||||
h += `<div class="captcha-error-msg" style="color: #e74c3c; font-size: 12px; margin-top: 5px; display: none;" aria-live="polite"></div>`;
|
||||
// bot trap - if this value is submitted server will ignore the request
|
||||
h += `<input type="text" name="p102xyzname" class="p102xyzname" value="">`;
|
||||
|
||||
@@ -118,6 +130,13 @@ function UIWindowSignup(options){
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize the captcha component with the required state
|
||||
const captchaContainer = $(el_window).find('.captcha-container')[0];
|
||||
const captcha = CaptchaView({
|
||||
container: captchaContainer,
|
||||
required: captchaRequired
|
||||
});
|
||||
|
||||
$(el_window).find('.login-c2a-clickable').on('click', async function(e){
|
||||
$('.login-c2a-clickable').parents('.window').close();
|
||||
const login = await UIWindowLogin({
|
||||
@@ -132,7 +151,44 @@ function UIWindowSignup(options){
|
||||
resolve(true);
|
||||
})
|
||||
|
||||
// Function to show captcha-specific error
|
||||
const showCaptchaError = (message) => {
|
||||
// Hide the general error message if shown
|
||||
$(el_window).find('.signup-error-msg').hide();
|
||||
|
||||
// Show captcha-specific error
|
||||
const captchaError = $(el_window).find('.captcha-error-msg');
|
||||
captchaError.html(message);
|
||||
captchaError.fadeIn();
|
||||
|
||||
// Add visual indication of error to captcha container
|
||||
$(captchaContainer).addClass('error');
|
||||
$(captchaContainer).css('border', '1px solid #e74c3c');
|
||||
$(captchaContainer).css('border-radius', '4px');
|
||||
$(captchaContainer).css('padding', '10px');
|
||||
|
||||
// Focus on the captcha input for better UX
|
||||
setTimeout(() => {
|
||||
const captchaInput = $(captchaContainer).find('.captcha-input');
|
||||
if (captchaInput.length) {
|
||||
captchaInput.focus();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Function to clear captcha errors
|
||||
const clearCaptchaError = () => {
|
||||
$(el_window).find('.captcha-error-msg').hide();
|
||||
$(captchaContainer).removeClass('error');
|
||||
$(captchaContainer).css('border', '');
|
||||
$(captchaContainer).css('padding', '');
|
||||
};
|
||||
|
||||
$(el_window).find('.signup-btn').on('click', function(e){
|
||||
// Clear previous error states
|
||||
$(el_window).find('.signup-error-msg').hide();
|
||||
clearCaptchaError();
|
||||
|
||||
//Username
|
||||
let username = $(el_window).find('.username').val();
|
||||
|
||||
@@ -175,6 +231,46 @@ function UIWindowSignup(options){
|
||||
return;
|
||||
}
|
||||
|
||||
// Get captcha token and answer if required
|
||||
let captchaToken = null;
|
||||
let captchaAnswer = null;
|
||||
|
||||
if (captcha.isRequired()) {
|
||||
captchaToken = captcha.getToken();
|
||||
captchaAnswer = captcha.getAnswer();
|
||||
|
||||
// Check if the captcha component is properly loaded
|
||||
if (!captcha || !captchaContainer) {
|
||||
$(el_window).find('.signup-error-msg').html(i18n('captcha_system_error') || 'Verification system error. Please refresh the page.');
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if captcha token exists
|
||||
if (!captchaToken) {
|
||||
showCaptchaError(i18n('captcha_load_error') || 'Could not load verification code. Please refresh the page or try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the answer is provided
|
||||
if (!captchaAnswer) {
|
||||
showCaptchaError(i18n('captcha_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if answer meets minimum length requirement
|
||||
if (captchaAnswer.trim().length < 3) {
|
||||
showCaptchaError(i18n('captcha_too_short') || 'Verification code answer is too short.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if answer meets maximum length requirement
|
||||
if (captchaAnswer.trim().length > 12) {
|
||||
showCaptchaError(i18n('captcha_too_long') || 'Verification code answer is too long.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//xyzname
|
||||
let p102xyzname = $(el_window).find('.p102xyzname').val();
|
||||
|
||||
@@ -185,28 +281,37 @@ function UIWindowSignup(options){
|
||||
if(window.custom_headers)
|
||||
headers = window.custom_headers;
|
||||
|
||||
// Include captcha in request only if required
|
||||
const requestData = {
|
||||
username: username,
|
||||
referral_code: window.referral_code,
|
||||
email: email,
|
||||
password: password,
|
||||
referrer: options.referrer ?? window.referrerStr,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
p102xyzname: p102xyzname,
|
||||
...(captchaToken && captchaAnswer ? {
|
||||
captchaToken: captchaToken,
|
||||
captchaAnswer: captchaAnswer
|
||||
} : {})
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: window.gui_origin + "/signup",
|
||||
type: 'POST',
|
||||
async: true,
|
||||
headers: headers,
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({
|
||||
username: username,
|
||||
referral_code: window.referral_code,
|
||||
email: email,
|
||||
password: password,
|
||||
referrer: options.referrer ?? window.referrerStr,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
p102xyzname: p102xyzname,
|
||||
}),
|
||||
data: JSON.stringify(requestData),
|
||||
success: async function (data){
|
||||
window.update_auth_data(data.token, data.user)
|
||||
|
||||
//send out the login event
|
||||
if(options.reload_on_success){
|
||||
window.onbeforeunload = null;
|
||||
window.location.replace('/');
|
||||
// Replace with a clean URL to prevent sensitive data leakage
|
||||
const cleanUrl = window.location.origin + window.location.pathname;
|
||||
window.location.replace(cleanUrl);
|
||||
}else if(options.send_confirmation_code){
|
||||
$(el_window).close();
|
||||
let is_verified = await UIWindowEmailConfirmationRequired({stay_on_top: true, has_head: true});
|
||||
@@ -216,11 +321,80 @@ function UIWindowSignup(options){
|
||||
}
|
||||
},
|
||||
error: function (err){
|
||||
$(el_window).find('.signup-error-msg').html(err.responseText);
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
// re-enable 'Create Account' button so user can try again
|
||||
$(el_window).find('.signup-btn').prop('disabled', false);
|
||||
}
|
||||
|
||||
// Process error response
|
||||
const errorText = err.responseText || '';
|
||||
const errorStatus = err.status || 0;
|
||||
|
||||
// Handle JSON error response
|
||||
try {
|
||||
// Try to parse error as JSON
|
||||
const errorJson = JSON.parse(errorText);
|
||||
|
||||
// Check for specific error codes
|
||||
if (errorJson.code === 'captcha_required') {
|
||||
// If captcha is now required but wasn't before, update the component
|
||||
if (!captcha.isRequired()) {
|
||||
captcha.setRequired(true);
|
||||
showCaptchaError(i18n('captcha_now_required') || 'Verification is now required. Please complete the verification below.');
|
||||
} else {
|
||||
showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorJson.code === 'captcha_invalid' || errorJson.code === 'captcha_error') {
|
||||
showCaptchaError(i18n('captcha_invalid') || 'Invalid verification code');
|
||||
// Refresh the captcha if it's invalid
|
||||
captcha.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a message in the JSON, use that
|
||||
if (errorJson.message) {
|
||||
$(el_window).find('.signup-error-msg').html(errorJson.message);
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, continue with text analysis
|
||||
}
|
||||
|
||||
// Check for specific captcha errors using more robust detection for text responses
|
||||
if (
|
||||
errorText.includes('captcha_required') ||
|
||||
errorText.includes('Captcha verification required') ||
|
||||
(errorText.includes('captcha') && errorText.includes('required'))
|
||||
) {
|
||||
showCaptchaError(i18n('captcha_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
errorText.includes('captcha_invalid') ||
|
||||
errorText.includes('Invalid captcha') ||
|
||||
(errorText.includes('captcha') && (errorText.includes('invalid') || errorText.includes('incorrect')))
|
||||
) {
|
||||
showCaptchaError(i18n('captcha_invalid'));
|
||||
// Refresh the captcha if it's invalid
|
||||
captcha.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle timeout specifically
|
||||
if (errorJson?.code === 'response_timeout' || errorText.includes('timeout')) {
|
||||
$(el_window).find('.signup-error-msg').html(i18n('server_timeout') || 'The server took too long to respond. Please try again.');
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Default general error handling
|
||||
$(el_window).find('.signup-error-msg').html(errorText || i18n('signup_error') || 'An error occurred during signup. Please try again.');
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
},
|
||||
timeout: 30000 // Add a reasonable timeout
|
||||
});
|
||||
})
|
||||
|
||||
|
||||
87
src/gui/src/helpers/captchaHelper.js
Normal file
87
src/gui/src/helpers/captchaHelper.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cache for captcha requirements to avoid repeated API calls
|
||||
*/
|
||||
let captchaRequirementsCache = null;
|
||||
|
||||
/**
|
||||
* Checks if captcha is required for a specific action
|
||||
*
|
||||
* This function first checks GUI parameters, then falls back to the /whoarewe endpoint
|
||||
*
|
||||
* @param {string} actionType - The type of action (e.g., 'login', 'signup')
|
||||
* @returns {Promise<boolean>} - Whether captcha is required for this action
|
||||
*/
|
||||
async function isCaptchaRequired(actionType) {
|
||||
console.log('CAPTCHA DIAGNOSTIC (Client): isCaptchaRequired called for', actionType);
|
||||
|
||||
// Check if we have the info in GUI parameters
|
||||
if (window.gui_params?.captchaRequired?.[actionType] !== undefined) {
|
||||
console.log(`CAPTCHA DIAGNOSTIC (Client): Requirement for ${actionType} from GUI params:`, window.gui_params.captchaRequired[actionType]);
|
||||
console.log('CAPTCHA DIAGNOSTIC (Client): Full gui_params.captchaRequired =', JSON.stringify(window.gui_params.captchaRequired));
|
||||
return window.gui_params.captchaRequired[actionType];
|
||||
}
|
||||
|
||||
// If not in GUI params, check the cache
|
||||
if (captchaRequirementsCache && captchaRequirementsCache.captchaRequired?.[actionType] !== undefined) {
|
||||
console.log(`CAPTCHA DIAGNOSTIC (Client): Requirement for ${actionType} from cache:`, captchaRequirementsCache.captchaRequired[actionType]);
|
||||
console.log('CAPTCHA DIAGNOSTIC (Client): Full cache =', JSON.stringify(captchaRequirementsCache.captchaRequired));
|
||||
return captchaRequirementsCache.captchaRequired[actionType];
|
||||
}
|
||||
|
||||
// If not in cache, fetch from the /whoarewe endpoint
|
||||
try {
|
||||
console.log(`CAPTCHA DIAGNOSTIC (Client): Fetching from /whoarewe for ${actionType}`);
|
||||
const response = await fetch(window.api_origin + '/whoarewe');
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`CAPTCHA DIAGNOSTIC (Client): Failed to get requirements: ${response.status}`);
|
||||
return true; // Default to requiring captcha if we can't determine
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`CAPTCHA DIAGNOSTIC (Client): /whoarewe response:`, data);
|
||||
|
||||
// Cache the result
|
||||
captchaRequirementsCache = data;
|
||||
|
||||
// Return the requirement or default to true if not specified
|
||||
const result = data.captchaRequired?.[actionType] ?? true;
|
||||
console.log(`CAPTCHA DIAGNOSTIC (Client): Final result for ${actionType}:`, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('CAPTCHA DIAGNOSTIC (Client): Error checking requirements:', error);
|
||||
return true; // Default to requiring captcha on error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the captcha requirements cache
|
||||
* This is useful when the requirements might have changed
|
||||
*/
|
||||
function invalidateCaptchaRequirementsCache() {
|
||||
captchaRequirementsCache = null;
|
||||
}
|
||||
|
||||
export {
|
||||
isCaptchaRequired,
|
||||
invalidateCaptchaRequirementsCache
|
||||
};
|
||||
@@ -410,6 +410,23 @@ const en = {
|
||||
'billing.expanded': 'Expanded',
|
||||
'billing.accelerated': 'Accelerated',
|
||||
'billing.enjoy_msg': 'Enjoy %% of Cloud Storage plus other benefits.',
|
||||
|
||||
// Captcha related strings
|
||||
'captcha_verification': 'Verification Code',
|
||||
'enter_captcha_text': 'Enter the text you see above',
|
||||
'refresh_captcha': 'Get a new code',
|
||||
'captcha_case_sensitive': 'Please enter the characters exactly as they appear. Case sensitive.',
|
||||
'captcha_required': 'Please complete the verification code.',
|
||||
'captcha_now_required': 'Verification is now required. Please complete the verification below.',
|
||||
'captcha_invalid': 'Incorrect verification code. Please try again.',
|
||||
'captcha_expired': 'Verification code has expired. Please try a new one.',
|
||||
'captcha_system_error': 'Verification system error. Please refresh the page.',
|
||||
'captcha_load_error': 'Could not load verification code. Please refresh the page or try again later.',
|
||||
'captcha_too_short': 'Verification code answer is too short.',
|
||||
'captcha_too_long': 'Verification code answer is too long.',
|
||||
'too_many_attempts': 'Too many attempts. Please try again later.',
|
||||
'server_timeout': 'The server took too long to respond. Please try again.',
|
||||
'signup_error': 'An error occurred during signup. Please try again.'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
window.puter_gui_enabled = true;
|
||||
|
||||
import { isCaptchaRequired } from './helpers/captchaHelper.js';
|
||||
|
||||
/**
|
||||
* Initializes and configures the GUI (Graphical User Interface) settings based on the provided options.
|
||||
*
|
||||
@@ -46,7 +48,7 @@ window.puter_gui_enabled = true;
|
||||
* });
|
||||
*/
|
||||
|
||||
window.gui = async function(options){
|
||||
window.gui = async (options) => {
|
||||
options = options ?? {};
|
||||
// app_origin is deprecated, use gui_origin instead
|
||||
window.gui_params = options;
|
||||
@@ -59,6 +61,18 @@ window.gui = async function(options){
|
||||
window.disable_temp_users = options.disable_temp_users ?? false;
|
||||
window.co_isolation_enabled = options.co_isolation_enabled;
|
||||
|
||||
// Preload captcha requirements if not already in GUI parameters
|
||||
if (!options.captchaRequired) {
|
||||
// Start loading in the background, but don't await
|
||||
// This way we don't delay the GUI initialization
|
||||
Promise.all([
|
||||
isCaptchaRequired('login'),
|
||||
isCaptchaRequired('signup')
|
||||
]).catch(err => {
|
||||
console.warn('Failed to preload captcha requirements:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// DEV: Load the initgui.js file if we are in development mode
|
||||
if(!window.gui_env || window.gui_env === "dev"){
|
||||
await window.loadScript('/sdk/puter.dev.js');
|
||||
|
||||
@@ -1426,3 +1426,11 @@ $(document).on('contextmenu', '.disable-context-menu', function(e){
|
||||
|
||||
// util/desktop.js
|
||||
window.privacy_aware_path = privacy_aware_path({ window });
|
||||
|
||||
$(window).on('system-logout-event', function(){
|
||||
// Clear cookie
|
||||
document.cookie = 'puter=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
||||
// Redirect to clean URL without any query parameters
|
||||
const cleanUrl = window.location.origin + window.location.pathname;
|
||||
window.location.replace(cleanUrl);
|
||||
});
|
||||
|
||||
188
src/gui/test/components/CaptchaView.test.js
Normal file
188
src/gui/test/components/CaptchaView.test.js
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import sinon from 'sinon';
|
||||
import jsdom from 'jsdom';
|
||||
|
||||
const { JSDOM } = jsdom;
|
||||
|
||||
// Mock the DOM environment
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
global.window = dom.window;
|
||||
global.document = dom.window.document;
|
||||
global.HTMLElement = dom.window.HTMLElement;
|
||||
global.customElements = dom.window.customElements;
|
||||
|
||||
// Mock the captchaHelper
|
||||
const captchaHelper = {
|
||||
isCaptchaRequired: sinon.stub()
|
||||
};
|
||||
|
||||
// Mock the grecaptcha object
|
||||
global.grecaptcha = {
|
||||
ready: sinon.stub().callsFake(cb => cb()),
|
||||
execute: sinon.stub().resolves('mock-token'),
|
||||
render: sinon.stub().returns('captcha-widget-id')
|
||||
};
|
||||
|
||||
// Import the module under test (mock import)
|
||||
const CaptchaView = {
|
||||
prototype: {
|
||||
connectedCallback: sinon.stub(),
|
||||
disconnectedCallback: sinon.stub(),
|
||||
setRequired: sinon.stub(),
|
||||
isRequired: sinon.stub(),
|
||||
getValue: sinon.stub(),
|
||||
reset: sinon.stub()
|
||||
}
|
||||
};
|
||||
|
||||
describe('CaptchaView', () => {
|
||||
let captchaElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mock CaptchaView element
|
||||
captchaElement = {
|
||||
...CaptchaView.prototype,
|
||||
getAttribute: sinon.stub(),
|
||||
setAttribute: sinon.stub(),
|
||||
removeAttribute: sinon.stub(),
|
||||
appendChild: sinon.stub(),
|
||||
querySelector: sinon.stub(),
|
||||
style: {},
|
||||
dataset: {},
|
||||
captchaWidgetId: null,
|
||||
captchaContainer: document.createElement('div')
|
||||
};
|
||||
|
||||
// Reset stubs
|
||||
Object.values(CaptchaView.prototype).forEach(stub => {
|
||||
if (typeof stub.reset === 'function') stub.reset();
|
||||
});
|
||||
|
||||
captchaHelper.isCaptchaRequired.reset();
|
||||
grecaptcha.ready.reset();
|
||||
grecaptcha.execute.reset();
|
||||
grecaptcha.render.reset();
|
||||
});
|
||||
|
||||
describe('setRequired', () => {
|
||||
it('should show captcha when required is true', () => {
|
||||
// Setup
|
||||
captchaElement.setRequired.callsFake(function(required) {
|
||||
this.required = required;
|
||||
if (required) {
|
||||
this.style.display = 'block';
|
||||
} else {
|
||||
this.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Test
|
||||
captchaElement.setRequired(true);
|
||||
|
||||
// Assert
|
||||
expect(captchaElement.required).to.be.true;
|
||||
expect(captchaElement.style.display).to.equal('block');
|
||||
});
|
||||
|
||||
it('should hide captcha when required is false', () => {
|
||||
// Setup
|
||||
captchaElement.setRequired.callsFake(function(required) {
|
||||
this.required = required;
|
||||
if (required) {
|
||||
this.style.display = 'block';
|
||||
} else {
|
||||
this.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Test
|
||||
captchaElement.setRequired(false);
|
||||
|
||||
// Assert
|
||||
expect(captchaElement.required).to.be.false;
|
||||
expect(captchaElement.style.display).to.equal('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRequired', () => {
|
||||
it('should return the current required state', () => {
|
||||
// Setup
|
||||
captchaElement.required = true;
|
||||
captchaElement.isRequired.callsFake(function() {
|
||||
return this.required;
|
||||
});
|
||||
|
||||
// Test & Assert
|
||||
expect(captchaElement.isRequired()).to.be.true;
|
||||
|
||||
// Change state
|
||||
captchaElement.required = false;
|
||||
|
||||
// Test & Assert again
|
||||
expect(captchaElement.isRequired()).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValue', () => {
|
||||
it('should return null when captcha is not required', () => {
|
||||
// Setup
|
||||
captchaElement.required = false;
|
||||
captchaElement.getValue.callsFake(function() {
|
||||
return this.required ? 'mock-token' : null;
|
||||
});
|
||||
|
||||
// Test & Assert
|
||||
expect(captchaElement.getValue()).to.be.null;
|
||||
});
|
||||
|
||||
it('should return token when captcha is required', () => {
|
||||
// Setup
|
||||
captchaElement.required = true;
|
||||
captchaElement.getValue.callsFake(function() {
|
||||
return this.required ? 'mock-token' : null;
|
||||
});
|
||||
|
||||
// Test & Assert
|
||||
expect(captchaElement.getValue()).to.equal('mock-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset the captcha widget when it exists', () => {
|
||||
// Setup
|
||||
captchaElement.captchaWidgetId = 'captcha-widget-id';
|
||||
global.grecaptcha.reset = sinon.stub();
|
||||
captchaElement.reset.callsFake(function() {
|
||||
if (this.captchaWidgetId) {
|
||||
grecaptcha.reset(this.captchaWidgetId);
|
||||
}
|
||||
});
|
||||
|
||||
// Test
|
||||
captchaElement.reset();
|
||||
|
||||
// Assert
|
||||
expect(grecaptcha.reset.calledWith('captcha-widget-id')).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
215
src/gui/test/helpers/captchaHelper.test.js
Normal file
215
src/gui/test/helpers/captchaHelper.test.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import sinon from 'sinon';
|
||||
|
||||
// Mock the fetch API and global window object for testing
|
||||
global.window = {
|
||||
api_origin: 'https://test-api.puter.com',
|
||||
gui_params: {}
|
||||
};
|
||||
global.fetch = sinon.stub();
|
||||
global.console = {
|
||||
log: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub()
|
||||
};
|
||||
|
||||
// Import the module under test
|
||||
import { isCaptchaRequired, invalidateCaptchaRequirementsCache } from '../../src/helpers/captchaHelper.js';
|
||||
|
||||
describe('captchaHelper', () => {
|
||||
let fetchStub;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset stubs before each test
|
||||
fetchStub = global.fetch;
|
||||
fetchStub.reset();
|
||||
|
||||
// Reset the window object
|
||||
global.window.gui_params = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Reset any cached data between tests
|
||||
invalidateCaptchaRequirementsCache();
|
||||
});
|
||||
|
||||
describe('isCaptchaRequired', () => {
|
||||
it('should use GUI parameters if available', async () => {
|
||||
// Setup
|
||||
global.window.gui_params = {
|
||||
captchaRequired: {
|
||||
login: true,
|
||||
signup: false
|
||||
}
|
||||
};
|
||||
|
||||
// Test
|
||||
const loginRequired = await isCaptchaRequired('login');
|
||||
const signupRequired = await isCaptchaRequired('signup');
|
||||
|
||||
// Assert
|
||||
expect(loginRequired).to.be.true;
|
||||
expect(signupRequired).to.be.false;
|
||||
expect(fetchStub.called).to.be.false; // Fetch should not be called
|
||||
});
|
||||
|
||||
it('should fetch from API if GUI parameters are not available', async () => {
|
||||
// Setup
|
||||
const apiResponse = {
|
||||
captchaRequired: {
|
||||
login: false,
|
||||
signup: true
|
||||
}
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(apiResponse)
|
||||
});
|
||||
|
||||
// Test
|
||||
const loginRequired = await isCaptchaRequired('login');
|
||||
|
||||
// Assert
|
||||
expect(loginRequired).to.be.false;
|
||||
expect(fetchStub.calledOnce).to.be.true;
|
||||
expect(fetchStub.firstCall.args[0]).to.equal('https://test-api.puter.com/whoarewe');
|
||||
});
|
||||
|
||||
it('should cache API responses for subsequent calls', async () => {
|
||||
// Setup
|
||||
const apiResponse = {
|
||||
captchaRequired: {
|
||||
login: true,
|
||||
signup: false
|
||||
}
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(apiResponse)
|
||||
});
|
||||
|
||||
// Test - first call should use the API
|
||||
const firstLoginRequired = await isCaptchaRequired('login');
|
||||
|
||||
// Second call should use the cache
|
||||
const secondLoginRequired = await isCaptchaRequired('login');
|
||||
|
||||
// Assert
|
||||
expect(firstLoginRequired).to.be.true;
|
||||
expect(secondLoginRequired).to.be.true;
|
||||
expect(fetchStub.calledOnce).to.be.true; // Fetch should only be called once
|
||||
});
|
||||
|
||||
it('should handle API errors and default to requiring captcha', async () => {
|
||||
// Setup
|
||||
fetchStub.rejects(new Error('Network error'));
|
||||
|
||||
// Test
|
||||
const loginRequired = await isCaptchaRequired('login');
|
||||
|
||||
// Assert
|
||||
expect(loginRequired).to.be.true; // Should default to true on error
|
||||
expect(fetchStub.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should handle non-200 API responses and default to requiring captcha', async () => {
|
||||
// Setup
|
||||
fetchStub.resolves({
|
||||
ok: false,
|
||||
status: 500
|
||||
});
|
||||
|
||||
// Test
|
||||
const loginRequired = await isCaptchaRequired('login');
|
||||
|
||||
// Assert
|
||||
expect(loginRequired).to.be.true; // Should default to true on error
|
||||
expect(fetchStub.calledOnce).to.be.true;
|
||||
});
|
||||
|
||||
it('should handle missing action type in response and default to requiring captcha', async () => {
|
||||
// Setup
|
||||
const apiResponse = {
|
||||
captchaRequired: {
|
||||
// login is missing
|
||||
signup: false
|
||||
}
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(apiResponse)
|
||||
});
|
||||
|
||||
// Test
|
||||
const loginRequired = await isCaptchaRequired('login');
|
||||
|
||||
// Assert
|
||||
expect(loginRequired).to.be.true; // Should default to true if not specified
|
||||
expect(fetchStub.calledOnce).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateCaptchaRequirementsCache', () => {
|
||||
it('should invalidate the cache and force a new API call', async () => {
|
||||
// Setup - first API call
|
||||
const firstApiResponse = {
|
||||
captchaRequired: {
|
||||
login: true
|
||||
}
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(firstApiResponse)
|
||||
});
|
||||
|
||||
// First call to cache the result
|
||||
await isCaptchaRequired('login');
|
||||
|
||||
// Setup - second API call with different response
|
||||
const secondApiResponse = {
|
||||
captchaRequired: {
|
||||
login: false
|
||||
}
|
||||
};
|
||||
|
||||
fetchStub.resolves({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(secondApiResponse)
|
||||
});
|
||||
|
||||
// Invalidate the cache
|
||||
invalidateCaptchaRequirementsCache();
|
||||
|
||||
// Test - this should now make a new API call
|
||||
const loginRequired = await isCaptchaRequired('login');
|
||||
|
||||
// Assert
|
||||
expect(loginRequired).to.be.false; // Should get the new value
|
||||
expect(fetchStub.calledTwice).to.be.true; // Fetch should be called twice
|
||||
});
|
||||
});
|
||||
});
|
||||
2862
test/integration/package-lock.json
generated
Normal file
2862
test/integration/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
test/integration/package.json
Normal file
20
test/integration/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "puter-integration-tests",
|
||||
"version": "1.0.0",
|
||||
"description": "Integration tests for Puter",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "mocha captcha/**/*.test.js",
|
||||
"test:auth": "mocha captcha/authentication-flow.test.js",
|
||||
"test:ui": "mocha captcha/ui-behavior.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.7",
|
||||
"express": "^4.18.2",
|
||||
"jsdom": "^21.1.0",
|
||||
"mocha": "^10.2.0",
|
||||
"sinon": "^15.2.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user